Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc99e9e504 | ||
|
|
2f35428232 | ||
|
|
00aac89ed7 | ||
|
|
f390e91bd7 | ||
|
|
e413359c8c | ||
|
|
601df7f808 | ||
|
|
e160a4add0 | ||
|
|
3bea03f4b1 | ||
|
|
19dbde8a3c | ||
|
|
a5fe10189f | ||
|
|
7075384313 | ||
|
|
25bf43cddd | ||
|
|
2880a681a8 | ||
|
|
bbcd0b7e78 | ||
|
|
916ed13bea | ||
|
|
11a06324be | ||
|
|
bcb340d97e | ||
|
|
aabef47ff1 | ||
|
|
e27de47d9f | ||
|
|
7900be3a30 | ||
|
|
ddc5838c7b | ||
|
|
6a13d7c5c6 |
Vendored
Vendored
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
@@ -11,13 +12,15 @@ export const getTemporaryOrgRedirect = async ({
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
prismaClient,
|
||||
}: {
|
||||
slugs: string[] | string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
prismaClient?: PrismaClient;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const prisma = prismaClient ?? (await import("@calcom/prisma")).default;
|
||||
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
orgDomainConfig,
|
||||
whereClauseForOrgWithSlugOrRequestedSlug,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -30,7 +32,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
@@ -41,6 +43,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
prismaClient: prisma,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
@@ -48,43 +51,49 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
}
|
||||
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
const [orgId, booking] = await Promise.all([
|
||||
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
|
||||
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
const team = await getTeamData(teamSlug, orgId);
|
||||
|
||||
if (!team) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
const eventData = team.eventTypes[0];
|
||||
const eventData = await getEventTypeData(meetingSlug, team.id);
|
||||
|
||||
if (!eventData) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
if (rescheduleUid && eventData.disableRescheduling) {
|
||||
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
|
||||
}
|
||||
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypeId = eventData.id;
|
||||
const eventHostsUserData = await getUsersData(
|
||||
team.isPrivate,
|
||||
eventTypeId,
|
||||
eventData.hosts.map((h) => h.user)
|
||||
);
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const eventHostsUserData = getEventHosts(
|
||||
team.isPrivate,
|
||||
eventData.hosts.map((h) => h.user),
|
||||
eventData.users ?? []
|
||||
);
|
||||
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
@@ -95,11 +104,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Handle string[] type from query params
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
|
||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||
? crmContactOwnerRecordType[0]
|
||||
: crmContactOwnerRecordType;
|
||||
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
@@ -122,7 +129,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
@@ -172,116 +178,149 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
};
|
||||
|
||||
const getTeamWithEventsData = async (
|
||||
teamSlug: string,
|
||||
meetingSlug: string,
|
||||
isValidOrgDomain: boolean,
|
||||
currentOrgDomain: string | null
|
||||
) => {
|
||||
const getOrgId = async (orgSlug: string): Promise<number | null> => {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org?.id ?? null;
|
||||
};
|
||||
|
||||
const getTeamData = async (teamSlug: string, orgId: number | null) => {
|
||||
const teamSelectFields = {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
isOrganization: true,
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (orgId !== null) {
|
||||
const publishedTeam = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug_parentId: {
|
||||
slug: teamSlug,
|
||||
parentId: orgId,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
select: teamSelectFields,
|
||||
});
|
||||
if (publishedTeam) return publishedTeam;
|
||||
}
|
||||
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
parentId: orgId ?? null,
|
||||
isOrganization: false,
|
||||
OR: [
|
||||
{ slug: teamSlug },
|
||||
{
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: teamSlug,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: teamSelectFields,
|
||||
orderBy: {
|
||||
slug: { sort: "asc", nulls: "last" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypeData = async (meetingSlug: string, teamId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
where: {
|
||||
// Use the EventType_teamId_slug_key unique index
|
||||
teamId_slug: {
|
||||
teamId: teamId,
|
||||
slug: meetingSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
parent: {
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
slug: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Include users for when hosts is empty
|
||||
users: {
|
||||
take: 1,
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getUsersData = async (
|
||||
const getEventHosts = (
|
||||
isPrivateTeam: boolean,
|
||||
eventTypeId: number,
|
||||
hosts: Pick<User, "username" | "name">[],
|
||||
users: Pick<User, "username" | "name">[]
|
||||
) => {
|
||||
if (!isPrivateTeam && users.length > 0) {
|
||||
return users
|
||||
if (isPrivateTeam) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (hosts.length > 0) {
|
||||
return hosts
|
||||
.filter((user) => user.username)
|
||||
.map((user) => ({
|
||||
username: user.username ?? "",
|
||||
name: user.name ?? "",
|
||||
}));
|
||||
}
|
||||
if (!isPrivateTeam && users.length === 0) {
|
||||
const { users: data } = await prisma.eventType.findUniqueOrThrow({
|
||||
where: { id: eventTypeId },
|
||||
select: {
|
||||
users: {
|
||||
take: 1,
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return data.length > 0
|
||||
? [
|
||||
{
|
||||
username: data[0].username ?? "",
|
||||
name: data[0].name ?? "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (users.length > 0) {
|
||||
return [
|
||||
{
|
||||
username: users[0].username ?? "",
|
||||
name: users[0].name ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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",
|
||||
@@ -3238,6 +3240,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",
|
||||
@@ -3251,5 +3257,8 @@
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -156,7 +156,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
let attendeeEmail: string | null = null;
|
||||
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
|
||||
if (!theBooking) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
const bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: uid,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}}>
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
+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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ const DateOverrideList = ({
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
tooltip={t("edit")}
|
||||
className="text-default"
|
||||
className="text-default h-5"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="pencil"
|
||||
@@ -116,7 +116,7 @@ const DateOverrideList = ({
|
||||
/>
|
||||
<Tooltip content="Delete">
|
||||
<Button
|
||||
className="text-default"
|
||||
className="text-default h-5"
|
||||
data-testid="delete-button"
|
||||
title={t("date_overrides_delete_on_date", {
|
||||
date: new Intl.DateTimeFormat(i18n.language, {
|
||||
|
||||
@@ -127,6 +127,7 @@ export function ScheduleListItem({
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
data-testid="delete-schedule"
|
||||
className="rounded-t-none"
|
||||
onClick={() => {
|
||||
if (!isDeletable) {
|
||||
showToast(t("requires_at_least_one_schedule"), "error");
|
||||
|
||||
@@ -545,13 +545,13 @@ describe("buildDateRanges", () => {
|
||||
});
|
||||
|
||||
expect(dateRanges[0]).toEqual({
|
||||
start: dayjs("2023-06-13T14:00:00Z").tz(timeZone),
|
||||
end: dayjs("2023-06-13T19:00:00Z").tz(timeZone),
|
||||
start: dayjs.utc("2023-06-13T14:00:00Z").tz(timeZone),
|
||||
end: dayjs.utc("2023-06-13T19:00:00Z").tz(timeZone),
|
||||
});
|
||||
|
||||
expect(dateRanges[1]).toEqual({
|
||||
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
|
||||
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
|
||||
start: dayjs.utc("2023-06-14T12:00:00Z").tz(timeZone),
|
||||
end: dayjs.utc("2023-06-14T21:00:00Z").tz(timeZone),
|
||||
});
|
||||
expect(oooExcludedDateRanges.length).toBe(1);
|
||||
expect(oooExcludedDateRanges[0]).toEqual({
|
||||
|
||||
@@ -132,12 +132,9 @@ export function processDateOverride({
|
||||
};
|
||||
}
|
||||
|
||||
// This function processes out-of-office dates and returns a date range for each OOO date.
|
||||
function processOOO(outOfOffice: Dayjs, timeZone: string) {
|
||||
const utcOffset = outOfOffice.tz(timeZone).utcOffset();
|
||||
const utcDate = outOfOffice.subtract(utcOffset, "minute");
|
||||
|
||||
const OOOdate = utcDate.tz(timeZone);
|
||||
|
||||
const OOOdate = outOfOffice.tz(timeZone, true);
|
||||
return {
|
||||
start: OOOdate,
|
||||
end: OOOdate,
|
||||
@@ -171,7 +168,7 @@ export function buildDateRanges({
|
||||
}, [])
|
||||
);
|
||||
const OOOdates = outOfOffice
|
||||
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs(outOfOffice), timeZone))
|
||||
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone))
|
||||
: [];
|
||||
|
||||
const groupedOOO = groupByDate(OOOdates);
|
||||
|
||||
@@ -135,6 +135,8 @@ const commons = {
|
||||
rrTimestampBasis: null,
|
||||
interfaceLanguage: null,
|
||||
customReplyToEmail: null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
profileId: null,
|
||||
requiresConfirmationWillBlockSlot: false,
|
||||
canSendCalVideoTranscriptionEmails: false,
|
||||
|
||||
@@ -20,4 +20,6 @@ export enum ErrorCode {
|
||||
BookingConflict = "booking_conflict_error",
|
||||
BookerLimitExceeded = "booker_limit_exceeded_error",
|
||||
BookerLimitExceededReschedule = "booker_limit_exceeded_error_reschedule",
|
||||
BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error",
|
||||
RestrictionScheduleNotFound = "restriction_schedule_not_found_error",
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export const getEventTypeById = async ({
|
||||
rawEventType.schedule?.id ||
|
||||
(!rawEventType.team ? rawEventType.users[0]?.defaultScheduleId : null) ||
|
||||
null,
|
||||
restrictionScheduleId: rawEventType.restrictionScheduleId || null,
|
||||
restrictionScheduleName: rawEventType.restrictionSchedule?.name || null,
|
||||
useBookerTimezone: rawEventType.useBookerTimezone || false,
|
||||
instantMeetingSchedule: rawEventType.instantMeetingSchedule?.id || null,
|
||||
scheduleName: rawEventType.schedule?.name || null,
|
||||
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
||||
|
||||
@@ -24,8 +24,6 @@ type UserWithProfile = UserWithoutProfile & {
|
||||
profile: Profile | null;
|
||||
};
|
||||
|
||||
type OrgSlug = string | null;
|
||||
|
||||
/**
|
||||
* Determines if branding should be hidden by checking entity and organization settings
|
||||
*/
|
||||
@@ -79,12 +77,12 @@ export async function shouldHideBrandingForEvent({
|
||||
eventTypeId,
|
||||
team,
|
||||
owner,
|
||||
orgSlug,
|
||||
organizationId,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
team: Team | null;
|
||||
owner: UserWithoutProfile | null;
|
||||
orgSlug: OrgSlug;
|
||||
organizationId: number | null;
|
||||
}) {
|
||||
let ownerProfile = null;
|
||||
if (team) {
|
||||
@@ -94,10 +92,10 @@ export async function shouldHideBrandingForEvent({
|
||||
});
|
||||
} else if (owner) {
|
||||
// Needed only for User events, not for Team events
|
||||
ownerProfile = orgSlug
|
||||
ownerProfile = organizationId
|
||||
? await ProfileRepository.findByUserIdAndOrgSlug({
|
||||
userId: owner.id,
|
||||
orgSlug,
|
||||
organizationId,
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -58,6 +58,17 @@ export default class LimitManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges busy times from another LimitManager
|
||||
*/
|
||||
mergeBusyTimes(otherManager: LimitManager) {
|
||||
otherManager.busyMap.forEach((busyTime, key) => {
|
||||
if (!this.busyMap.has(key)) {
|
||||
this.busyMap.set(key, busyTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all busy times
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
/**
|
||||
* Checks if the restriction schedule feature is enabled for a team
|
||||
* @param teamId - The team ID to check
|
||||
* @returns Promise<boolean> - True if the feature is enabled, false otherwise
|
||||
*/
|
||||
export async function isRestrictionScheduleEnabled(teamId?: number): Promise<boolean> {
|
||||
if (!teamId) {
|
||||
return false; // Personal events don't have restriction schedules
|
||||
}
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
return await featureRepo.checkIfTeamHasFeature(teamId, "restriction-schedule");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
export const eventTypeSelect = {
|
||||
id: true,
|
||||
teamId: true,
|
||||
schedulingType: true,
|
||||
@@ -71,4 +71,29 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
maxLeadThreshold: true,
|
||||
useEventLevelSelectedCalendars: true,
|
||||
customReplyToEmail: true,
|
||||
});
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
instantMeetingSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.EventTypeSelect;
|
||||
|
||||
// Create a separate select for schedule-related fields
|
||||
export const eventTypeScheduleSelect = {
|
||||
...eventTypeSelect,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
restrictionSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.EventTypeSelect;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
|
||||
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
|
||||
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
|
||||
import { getRoutedUrl } from "./getRoutedUrl";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
|
||||
vi.mock("@calcom/lib/server/repository/routingForm");
|
||||
vi.mock("@calcom/lib/server/repository/user");
|
||||
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
|
||||
vi.mock("@calcom/features/routing-forms/lib/isAuthorizedToViewForm");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/processRoute");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/substituteVariables");
|
||||
vi.mock("@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward");
|
||||
vi.mock("@calcom/app-store/routing-forms/getEventTypeRedirectUrl");
|
||||
vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({
|
||||
enrichFormWithMigrationData: vi.fn((form) => form),
|
||||
}));
|
||||
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
||||
withReporting: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
const mockForm = {
|
||||
id: "form-id",
|
||||
user: { id: 1, name: "Test User" },
|
||||
team: null,
|
||||
name: "Test Form",
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
fieldsOrder: [],
|
||||
position: 0,
|
||||
slug: "test-form",
|
||||
};
|
||||
|
||||
const mockSerializableForm = {
|
||||
id: "form-id",
|
||||
fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }],
|
||||
routes: [],
|
||||
user: { id: 1 },
|
||||
};
|
||||
|
||||
const mockContext = (
|
||||
query: Record<string, unknown>,
|
||||
url = "/link/form-id"
|
||||
): Pick<GetServerSidePropsContext, "query" | "req"> => ({
|
||||
query: { form: "form-id", ...query },
|
||||
req: { url },
|
||||
});
|
||||
|
||||
describe("getRoutedUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Provide default mock implementations
|
||||
vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null });
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
vi.mocked(UserRepository.enrichUserWithItsProfile).mockImplementation(async ({ user }) => user);
|
||||
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(null);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
});
|
||||
vi.mocked(substituteVariables).mockImplementation((value) => value);
|
||||
vi.mocked(getUrlSearchParamsToForward).mockReturnValue(new URLSearchParams());
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockImplementation(
|
||||
({ eventTypeRedirectUrl }) => eventTypeRedirectUrl
|
||||
);
|
||||
vi.mocked(getResponseToStore).mockReturnValue({ email: "test@cal.com" });
|
||||
});
|
||||
|
||||
it("should return notFound if query params are invalid", async () => {
|
||||
const context = {
|
||||
query: { wrong_param: "value" }, // 'form' is missing
|
||||
req: { url: "/link/form-id" },
|
||||
};
|
||||
// @ts-expect-error we are testing invalid input
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
});
|
||||
|
||||
it("should return notFound if form is not found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
|
||||
});
|
||||
|
||||
it("should return notFound if user is not authorized", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(false);
|
||||
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(isAuthorizedToViewFormOnOrgDomain).toHaveBeenCalledWith({
|
||||
user: mockForm.user,
|
||||
team: mockForm.team,
|
||||
currentOrgDomain: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw an error if no matching route is found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(null);
|
||||
|
||||
const context = mockContext({});
|
||||
await expect(getRoutedUrl(context)).rejects.toThrow("No matching route could be found");
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return props with a message for customPageMessage action", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const message = "This is a custom message";
|
||||
const mockRoute = { id: "route1", action: { type: "customPageMessage", value: message } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
props: {
|
||||
isEmbed: false,
|
||||
form: mockSerializableForm,
|
||||
message: message,
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return a redirect for eventTypeRedirectUrl action and substitute variables", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min/{email}";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
const substitutedUrl = "test-user/30min/test@cal.com";
|
||||
vi.mocked(substituteVariables).mockReturnValue(substitutedUrl);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${substitutedUrl}`);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(substituteVariables).toHaveBeenCalledWith(
|
||||
redirectUrl,
|
||||
{ email: "test@cal.com" },
|
||||
mockSerializableForm.fields
|
||||
);
|
||||
expect(getUrlSearchParamsToForward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
formResponse: { email: "test@cal.com" },
|
||||
fields: mockSerializableForm.fields,
|
||||
})
|
||||
);
|
||||
expect(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventTypeRedirectUrl: substitutedUrl,
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `/${substitutedUrl}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a redirect for externalRedirectUrl action", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "https://example.com";
|
||||
const mockRoute = { id: "route1", action: { type: "externalRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
...(context.query as Record<string, string>),
|
||||
"cal.action": "externalRedirectUrl",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `${redirectUrl}?${searchParams.toString()}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleResponse with correct response prop obtained from getResponseToStore", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
|
||||
vi.mocked(getResponseToStore).mockReturnValue({ "xxx-xxx": { value: "test@cal.com" } } as never);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
await getRoutedUrl(context);
|
||||
|
||||
expect(getResponseToStore).toHaveBeenCalledWith({
|
||||
formFields: mockSerializableForm.fields,
|
||||
fieldsResponses: { email: "test@cal.com" },
|
||||
});
|
||||
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ response: { "xxx-xxx": { value: "test@cal.com" } } })
|
||||
);
|
||||
});
|
||||
|
||||
describe("Dry Run", () => {
|
||||
it("should call handleResponse with isPreview:true", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
|
||||
|
||||
const context = mockContext({ "cal.isBookingDryRun": "true" });
|
||||
await getRoutedUrl(context);
|
||||
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPreview: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -394,6 +394,8 @@ export class BookingRepository {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
redirectUrlOnExit: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ export class CalVideoSettingsRepository {
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests?: boolean | null;
|
||||
disableRecordingForOrganizer?: boolean | null;
|
||||
disableTranscriptionForGuests?: boolean | null;
|
||||
disableTranscriptionForOrganizer?: boolean | null;
|
||||
enableAutomaticTranscription?: boolean | null;
|
||||
redirectUrlOnExit?: string | null;
|
||||
};
|
||||
@@ -25,6 +27,8 @@ export class CalVideoSettingsRepository {
|
||||
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
|
||||
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
|
||||
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
|
||||
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
|
||||
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
|
||||
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -32,6 +36,8 @@ export class CalVideoSettingsRepository {
|
||||
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
|
||||
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
|
||||
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
|
||||
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
|
||||
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
|
||||
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
|
||||
eventTypeId,
|
||||
},
|
||||
|
||||
@@ -592,6 +592,8 @@ export class EventTypeRepository {
|
||||
},
|
||||
},
|
||||
},
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
@@ -608,6 +610,12 @@ export class EventTypeRepository {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
restrictionSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
@@ -698,6 +706,8 @@ export class EventTypeRepository {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
redirectUrlOnExit: true,
|
||||
},
|
||||
},
|
||||
@@ -840,6 +850,8 @@ export class EventTypeRepository {
|
||||
maxLeadThreshold: true,
|
||||
includeNoShowInRRCalculation: true,
|
||||
useEventLevelSelectedCalendars: true,
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
|
||||
import { availabilityUserSelect, prisma, type PrismaTransaction, type PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
@@ -70,6 +70,37 @@ const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipW
|
||||
};
|
||||
|
||||
export class MembershipRepository {
|
||||
constructor(private readonly prismaClient: PrismaClient = prisma) {}
|
||||
|
||||
async hasMembership({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
|
||||
const membership = await this.prismaClient.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return !!membership;
|
||||
}
|
||||
|
||||
async listAcceptedTeamMemberIds({ teamId }: { teamId: number }): Promise<number[]> {
|
||||
const memberships =
|
||||
(await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
})) || [];
|
||||
const teamMemberIds = memberships.map((membership) => membership.userId);
|
||||
return teamMemberIds;
|
||||
}
|
||||
|
||||
static async create(data: IMembership) {
|
||||
return await prisma.membership.create({
|
||||
data: {
|
||||
|
||||
@@ -665,9 +665,20 @@ export class ProfileRepository {
|
||||
return normalizeProfile(profile);
|
||||
}
|
||||
|
||||
static async findByUserIdAndOrgSlug({ userId, orgSlug }: { userId: number; orgSlug: string }) {
|
||||
const profile = await prisma.profile.findFirst({
|
||||
where: { userId, organization: { slug: orgSlug } },
|
||||
static async findByUserIdAndOrgSlug({
|
||||
userId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: number;
|
||||
organizationId: number;
|
||||
}) {
|
||||
const profile = await prisma.profile.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: organizationSelect,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
|
||||
|
||||
@@ -13,6 +14,56 @@ export type FindDetailedScheduleByIdReturnType = Awaited<
|
||||
>;
|
||||
|
||||
export class ScheduleRepository {
|
||||
// when instantiating, prismaClient injection is required
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
async findScheduleByIdForBuildDateRanges({ scheduleId }: { scheduleId: number }) {
|
||||
const schedule = await this.prismaClient.schedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async findScheduleByIdForOwnershipCheck({ scheduleId }: { scheduleId: number }) {
|
||||
const schedule = await this.prismaClient.schedule.findUnique({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
return schedule;
|
||||
}
|
||||
|
||||
static async findScheduleById({ id }: { id: number }) {
|
||||
const schedule = await prisma.schedule.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -157,6 +157,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
allowReschedulingPastBookings: false,
|
||||
hideOrganizerEmail: false,
|
||||
customReplyToEmail: null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
...eventType,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -177,6 +177,8 @@ export const useEventTypeForm = ({
|
||||
disableRecordingForOrganizer: z.boolean().nullable(),
|
||||
disableRecordingForGuests: z.boolean().nullable(),
|
||||
enableAutomaticTranscription: z.boolean().nullable(),
|
||||
disableTranscriptionForGuests: z.boolean().nullable(),
|
||||
disableTranscriptionForOrganizer: z.boolean().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
|
||||
@@ -22,6 +22,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const scheduleId = formMethods.watch("schedule");
|
||||
const restrictionScheduleId = formMethods.watch("restrictionScheduleId");
|
||||
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType: props.eventType,
|
||||
@@ -39,6 +40,15 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
{ enabled: !!scheduleId || (!props.isTeamEvent && !!props.user?.defaultScheduleId) }
|
||||
);
|
||||
|
||||
const { isPending: isRestrictionSchedulePending, data: restrictionScheduleQueryData } =
|
||||
trpc.viewer.availability.schedule.get.useQuery(
|
||||
{
|
||||
scheduleId: restrictionScheduleId || undefined,
|
||||
isManagedEventType: isManagedEventType || isChildrenManagedEventType,
|
||||
},
|
||||
{ enabled: !!restrictionScheduleId }
|
||||
);
|
||||
|
||||
const { data: schedulesQueryData, isPending: isSchedulesPending } =
|
||||
trpc.viewer.availability.list.useQuery(undefined);
|
||||
|
||||
@@ -51,7 +61,10 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
isSchedulesPending={isSchedulesPending}
|
||||
isSchedulePending={isSchedulePending}
|
||||
scheduleQueryData={scheduleQueryData}
|
||||
restrictionScheduleQueryData={restrictionScheduleQueryData}
|
||||
isRestrictionSchedulePending={isRestrictionSchedulePending}
|
||||
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
|
||||
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
|
||||
hostSchedulesQuery={hostSchedulesQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "restrictionScheduleId" INTEGER,
|
||||
ADD COLUMN "useBookerTimezone" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventType" ADD CONSTRAINT "EventType_restrictionScheduleId_fkey" FOREIGN KEY ("restrictionScheduleId") REFERENCES "Schedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
INSERT INTO "Feature" (slug, enabled) VALUES ('restriction-schedule', false) ON CONFLICT (slug) DO NOTHING;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventType_restrictionScheduleId_idx" ON "EventType"("restrictionScheduleId");
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CalVideoSettings" ADD COLUMN "disableTranscriptionForGuests" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "disableTranscriptionForOrganizer" BOOLEAN NOT NULL DEFAULT false;
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
-- Shared function that contains the core processing logic
|
||||
CREATE OR REPLACE FUNCTION _process_routing_form_response_fields(response_id integer, response_data jsonb, form_id text)
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
form_fields jsonb;
|
||||
field_record jsonb;
|
||||
response_field jsonb;
|
||||
field_type text;
|
||||
BEGIN
|
||||
-- Validate response_data exists and is a valid JSON object
|
||||
IF response_data IS NULL OR jsonb_typeof(response_data) != 'object' THEN
|
||||
RAISE WARNING 'Invalid response data for id %. Type: %', response_id, COALESCE(jsonb_typeof(response_data), 'null');
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Get the fields from App_RoutingForms_Form
|
||||
SELECT fields::jsonb INTO form_fields
|
||||
FROM "App_RoutingForms_Form"
|
||||
WHERE id = form_id;
|
||||
|
||||
-- Delete existing entries for this response
|
||||
DELETE FROM "RoutingFormResponseField"
|
||||
WHERE "responseId" = response_id;
|
||||
|
||||
-- Exit early if form_fields is NULL or not an array
|
||||
IF form_fields IS NULL OR jsonb_typeof(form_fields) != 'array' THEN
|
||||
RAISE WARNING 'form_fields is NULL or not an array for formId %, skipping processing', form_id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Iterate through each field in the response
|
||||
FOR field_record IN SELECT * FROM jsonb_array_elements(form_fields)
|
||||
LOOP
|
||||
BEGIN
|
||||
-- Validate field_record has required properties
|
||||
IF field_record->>'id' IS NULL THEN
|
||||
RAISE WARNING 'Field record is missing id property, skipping field';
|
||||
CONTINUE;
|
||||
END IF;
|
||||
IF field_record->>'type' IS NULL THEN
|
||||
RAISE WARNING 'Field record % is missing type property, skipping field', field_record->>'id';
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- Get the response field object for this field
|
||||
response_field := response_data->(field_record->>'id');
|
||||
|
||||
-- Skip if no response for this field
|
||||
IF response_field IS NULL THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- Get field type
|
||||
field_type := field_record->>'type';
|
||||
|
||||
-- Insert new record based on field type
|
||||
IF field_type = 'multiselect' THEN
|
||||
-- Handle array values for multiselect
|
||||
-- Accept both array values and string values
|
||||
IF response_field->>'value' IS NOT NULL THEN
|
||||
BEGIN
|
||||
IF jsonb_typeof(response_field->'value') = 'array' THEN
|
||||
-- If it's already an array, use it directly
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueStringArray")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
ARRAY(SELECT jsonb_array_elements_text(response_field->'value'))
|
||||
);
|
||||
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
|
||||
-- If it's a string, convert it to a single-element array
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueStringArray")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
ARRAY[response_field->>'value']
|
||||
);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to insert multiselect values for field %', field_record->>'id';
|
||||
END;
|
||||
END IF;
|
||||
ELSIF field_type = 'number' THEN
|
||||
-- Handle number values
|
||||
-- Accept both JSON number values and string values that can be converted to numbers
|
||||
IF response_field->>'value' IS NOT NULL THEN
|
||||
BEGIN
|
||||
IF jsonb_typeof(response_field->'value') = 'number' THEN
|
||||
-- If it's already a JSON number, use it directly
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueNumber")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
(response_field->>'value')::decimal
|
||||
);
|
||||
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
|
||||
-- If it's a string, try to convert it to a number
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueNumber")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
(response_field->>'value')::decimal
|
||||
);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to insert number value for field % (value: %)', field_record->>'id', response_field->>'value';
|
||||
END;
|
||||
END IF;
|
||||
ELSIF field_type = 'select' THEN
|
||||
-- Handle select values - can be either string or array
|
||||
IF response_field->>'value' IS NOT NULL THEN
|
||||
BEGIN
|
||||
IF jsonb_typeof(response_field->'value') = 'array' THEN
|
||||
-- If it's an array, take the first element
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
(response_field->'value'->>0)
|
||||
);
|
||||
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
|
||||
-- If it's a string, use it directly
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
response_field->>'value'
|
||||
);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to insert select value for field %', field_record->>'id';
|
||||
END;
|
||||
END IF;
|
||||
ELSE
|
||||
-- Handle all other types as strings
|
||||
-- Only insert if value is not null and is a valid string
|
||||
IF response_field->>'value' IS NOT NULL AND jsonb_typeof(response_field->'value') = 'string' THEN
|
||||
BEGIN
|
||||
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
|
||||
VALUES (
|
||||
response_id,
|
||||
field_record->>'id',
|
||||
response_field->>'value'
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to insert string value for field %', field_record->>'id';
|
||||
END;
|
||||
END IF;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Log the error and continue processing other fields
|
||||
RAISE WARNING 'Error processing field %', field_record->>'id';
|
||||
CONTINUE;
|
||||
END;
|
||||
END LOOP;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Log any unhandled errors and continue
|
||||
RAISE WARNING 'Unhandled error in _process_routing_form_response_fields for response %', response_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Updated trigger function that uses the shared core function
|
||||
CREATE OR REPLACE FUNCTION handle_routing_form_response_fields()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
response_data jsonb;
|
||||
form_id text;
|
||||
BEGIN
|
||||
-- For INSERT trigger on RoutingFormResponseDenormalized, we need to get the response data from App_RoutingForms_FormResponse
|
||||
-- For UPDATE trigger on App_RoutingForms_FormResponse, we can use NEW.response directly
|
||||
IF TG_TABLE_NAME = 'RoutingFormResponseDenormalized' THEN
|
||||
SELECT response, "formId" INTO response_data, form_id
|
||||
FROM "App_RoutingForms_FormResponse"
|
||||
WHERE id = NEW.id;
|
||||
ELSE
|
||||
response_data := NEW.response::jsonb;
|
||||
form_id := NEW."formId";
|
||||
END IF;
|
||||
|
||||
-- Call the shared core function
|
||||
PERFORM _process_routing_form_response_fields(NEW.id, response_data, form_id);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- New function to reprocess routing form response fields for a specific response ID
|
||||
CREATE OR REPLACE FUNCTION reprocess_routing_form_response_fields(response_id integer)
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
response_data jsonb;
|
||||
form_id text;
|
||||
BEGIN
|
||||
-- Get the response data and form ID from App_RoutingForms_FormResponse
|
||||
SELECT response, "formId" INTO response_data, form_id
|
||||
FROM "App_RoutingForms_FormResponse"
|
||||
WHERE id = response_id;
|
||||
|
||||
-- Check if the response exists
|
||||
IF response_data IS NULL THEN
|
||||
RAISE EXCEPTION 'Response with id % not found', response_id;
|
||||
END IF;
|
||||
|
||||
-- Call the shared core function
|
||||
PERFORM _process_routing_form_response_fields(response_id, response_data, form_id);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -72,10 +72,12 @@ model CalVideoSettings {
|
||||
eventTypeId Int @id
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
|
||||
disableRecordingForOrganizer Boolean @default(false)
|
||||
disableRecordingForGuests Boolean @default(false)
|
||||
enableAutomaticTranscription Boolean @default(false)
|
||||
redirectUrlOnExit String?
|
||||
disableRecordingForOrganizer Boolean @default(false)
|
||||
disableRecordingForGuests Boolean @default(false)
|
||||
enableAutomaticTranscription Boolean @default(false)
|
||||
redirectUrlOnExit String?
|
||||
disableTranscriptionForGuests Boolean @default(false)
|
||||
disableTranscriptionForOrganizer Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -201,6 +203,10 @@ model EventType {
|
||||
secondaryEmailId Int?
|
||||
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
|
||||
|
||||
useBookerTimezone Boolean @default(false)
|
||||
restrictionScheduleId Int?
|
||||
restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
|
||||
|
||||
@@unique([userId, slug])
|
||||
@@unique([teamId, slug])
|
||||
@@unique([userId, parentId])
|
||||
@@ -210,6 +216,7 @@ model EventType {
|
||||
@@index([scheduleId])
|
||||
@@index([secondaryEmailId])
|
||||
@@index([parentId])
|
||||
@@index([restrictionScheduleId])
|
||||
}
|
||||
|
||||
model Credential {
|
||||
@@ -811,6 +818,7 @@ model Schedule {
|
||||
userId Int
|
||||
eventType EventType[]
|
||||
instantMeetingEvents EventType[] @relation("InstantMeetingSchedule")
|
||||
restrictionSchedule EventType[] @relation("restrictionSchedule")
|
||||
name String
|
||||
timeZone String?
|
||||
availability Availability[]
|
||||
|
||||
@@ -228,7 +228,13 @@ export type IntervalLimitsType = IntervalLimit | null;
|
||||
|
||||
export { intervalLimitsType } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
|
||||
export const eventTypeSlug = z.string().transform((val) => slugify(val.trim()));
|
||||
export const eventTypeSlug = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) => slugify(val))
|
||||
.refine((val) => val.length >= 1, {
|
||||
message: "Please enter at least one character",
|
||||
});
|
||||
|
||||
export const stringToDate = z.string().transform((a) => new Date(a));
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
|
||||
descriptionAsSafeHTML: _descriptionAsSafeHTML,
|
||||
secondaryEmailId,
|
||||
instantMeetingScheduleId: _instantMeetingScheduleId,
|
||||
restrictionScheduleId: _restrictionScheduleId,
|
||||
...rest
|
||||
} = eventType;
|
||||
|
||||
@@ -113,7 +114,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
restrictionSchedule: _restrictionScheduleId
|
||||
? {
|
||||
connect: {
|
||||
id: _restrictionScheduleId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
recurringEvent: recurringEvent || undefined,
|
||||
bookingLimits: bookingLimits ?? undefined,
|
||||
durationLimits: durationLimits ?? undefined,
|
||||
|
||||
@@ -30,6 +30,8 @@ const calVideoSettingsSchema = z
|
||||
disableRecordingForGuests: z.boolean().optional().nullable(),
|
||||
disableRecordingForOrganizer: z.boolean().optional().nullable(),
|
||||
enableAutomaticTranscription: z.boolean().optional().nullable(),
|
||||
disableTranscriptionForGuests: z.boolean().optional().nullable(),
|
||||
disableTranscriptionForOrganizer: z.boolean().optional().nullable(),
|
||||
redirectUrlOnExit: z.string().url().optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
@@ -13,6 +13,8 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
|
||||
@@ -87,6 +89,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
description: newDescription,
|
||||
title: newTitle,
|
||||
seatsPerTimeSlot,
|
||||
restrictionScheduleId,
|
||||
calVideoSettings,
|
||||
...rest
|
||||
} = input;
|
||||
@@ -127,6 +130,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
disableRecordingForOrganizer: true,
|
||||
disableRecordingForGuests: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
redirectUrlOnExit: true,
|
||||
},
|
||||
},
|
||||
@@ -338,6 +343,45 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
};
|
||||
}
|
||||
|
||||
const membershipRepo = new MembershipRepository(ctx.prisma);
|
||||
|
||||
if (restrictionScheduleId) {
|
||||
// Verify that the user owns the restriction schedule or is a team member
|
||||
const scheduleRepo = new ScheduleRepository(ctx.prisma);
|
||||
const restrictionSchedule = await scheduleRepo.findScheduleByIdForOwnershipCheck({
|
||||
scheduleId: restrictionScheduleId,
|
||||
});
|
||||
// If the user doesn't own the schedule, check if they're a team member
|
||||
if (restrictionSchedule?.userId !== ctx.user.id) {
|
||||
if (!teamId || !restrictionSchedule) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "The restriction schedule is not owned by you or your team",
|
||||
});
|
||||
}
|
||||
const hasMembership = await membershipRepo.hasMembership({
|
||||
teamId,
|
||||
userId: restrictionSchedule.userId,
|
||||
});
|
||||
if (!hasMembership) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "The restriction schedule is not owned by you or your team",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.restrictionSchedule = {
|
||||
connect: {
|
||||
id: restrictionScheduleId,
|
||||
},
|
||||
};
|
||||
} else if (restrictionScheduleId === null || restrictionScheduleId === 0) {
|
||||
data.restrictionSchedule = {
|
||||
disconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (users?.length) {
|
||||
data.users = {
|
||||
set: [],
|
||||
@@ -347,14 +391,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
|
||||
if (teamId && hosts) {
|
||||
// check if all hosts can be assigned (memberships that have accepted invite)
|
||||
const memberships =
|
||||
(await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
})) || [];
|
||||
const teamMemberIds = memberships.map((membership) => membership.userId);
|
||||
const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId });
|
||||
// guard against missing IDs, this may mean a member has just been removed
|
||||
// or this request was forged.
|
||||
// we let this pass through on organization sub-teams
|
||||
|
||||
@@ -13,24 +13,21 @@ type checkInvalidAppCredentialsOptions = {
|
||||
export const checkInvalidAppCredentials = async ({ ctx }: checkInvalidAppCredentialsOptions) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// First get the teams where user is admin/owner
|
||||
const userTeamIds = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const apps = await prisma.credential.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
OR: [{ userId }, { teamId: { in: userTeamIds.map((membership) => membership.teamId) } }],
|
||||
invalid: true,
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -161,7 +161,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
metadata: mergeMetadata({ ...input.metadata }),
|
||||
};
|
||||
|
||||
if (input.banner && input.banner.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.banner &&
|
||||
(input.banner.startsWith("data:image/png;base64,") ||
|
||||
input.banner.startsWith("data:image/jpeg;base64,") ||
|
||||
input.banner.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
|
||||
data.bannerUrl = await uploadLogo({
|
||||
logo: banner,
|
||||
@@ -172,7 +177,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
data.bannerUrl = null;
|
||||
}
|
||||
|
||||
if (input.logoUrl && input.logoUrl.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.logoUrl &&
|
||||
(input.logoUrl.startsWith("data:image/png;base64,") ||
|
||||
input.logoUrl.startsWith("data:image/jpeg;base64,") ||
|
||||
input.logoUrl.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
data.logoUrl = await uploadLogo({
|
||||
logo: await resizeBase64Image(input.logoUrl),
|
||||
teamId: currentOrgId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShou
|
||||
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
|
||||
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
||||
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
|
||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getAggregatedAvailability } from "@calcom/lib/getAggregatedAvailability";
|
||||
@@ -29,13 +30,14 @@ import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
||||
import LimitManager from "@calcom/lib/intervalLimits/limitManager";
|
||||
import { checkBookingLimit } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
|
||||
import { getUnitFromBusyTime, isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils";
|
||||
import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils";
|
||||
import {
|
||||
calculatePeriodLimits,
|
||||
isTimeOutOfBounds,
|
||||
isTimeViolatingFutureLimit,
|
||||
} from "@calcom/lib/isOutOfBounds";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { isRestrictionScheduleEnabled } from "@calcom/lib/restrictionSchedule";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
|
||||
@@ -497,6 +499,94 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
|
||||
let availableTimeSlots: typeof timeSlots = [];
|
||||
const bookerClientUid = ctx?.req?.cookies?.uid;
|
||||
const isRestrictionScheduleFeatureEnabled = await isRestrictionScheduleEnabled(eventType.team?.id);
|
||||
if (eventType.restrictionScheduleId && isRestrictionScheduleFeatureEnabled) {
|
||||
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) {
|
||||
if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) {
|
||||
throw new TRPCError({
|
||||
message: "No timezone is set for the restricted schedule",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const restrictionTimezone = eventType.useBookerTimezone
|
||||
? input.timeZone
|
||||
: restrictionSchedule.timeZone!;
|
||||
const eventLength = input.duration || eventType.length;
|
||||
|
||||
const restrictionAvailability = restrictionSchedule.availability.map((rule) => ({
|
||||
days: rule.days,
|
||||
startTime: rule.startTime,
|
||||
endTime: rule.endTime,
|
||||
date: rule.date,
|
||||
}));
|
||||
|
||||
// Include travel schedules if restriction schedule is the user's default schedule
|
||||
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 || "UTC",
|
||||
dateFrom: startTime,
|
||||
dateTo: endTime,
|
||||
travelSchedules,
|
||||
});
|
||||
|
||||
availableTimeSlots = timeSlots.filter((slot) => {
|
||||
const slotStart = slot.time;
|
||||
const slotEnd = slot.time.add(eventLength, "minute");
|
||||
|
||||
return restrictionRanges.some(
|
||||
(range) =>
|
||||
(slotStart.isAfter(range.start) || slotStart.isSame(range.start)) &&
|
||||
(slotEnd.isBefore(range.end) || slotEnd.isSame(range.end))
|
||||
);
|
||||
});
|
||||
} else {
|
||||
availableTimeSlots = timeSlots;
|
||||
}
|
||||
} else {
|
||||
availableTimeSlots = timeSlots;
|
||||
}
|
||||
|
||||
const reservedSlots = await _getReservedSlotsAndCleanupExpired({
|
||||
bookerClientUid,
|
||||
@@ -504,8 +594,6 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
usersWithCredentials,
|
||||
});
|
||||
|
||||
availableTimeSlots = timeSlots;
|
||||
|
||||
const availabilityCheckProps = {
|
||||
eventLength: input.duration || eventType.length,
|
||||
currentSeats,
|
||||
@@ -943,13 +1031,7 @@ const _getBusyTimesFromLimitsForUsers = async (
|
||||
const userBookings = busyTimesFromLimitsBookings.filter((booking) => booking.userId === user.id);
|
||||
const limitManager = new LimitManager();
|
||||
|
||||
for (const busyTime of globalLimitManager.getBusyTimes()) {
|
||||
const start = dayjs(busyTime.start);
|
||||
const end = dayjs(busyTime.end);
|
||||
const unit = getUnitFromBusyTime(start, end);
|
||||
|
||||
limitManager.addBusyTime(start, unit, timeZone);
|
||||
}
|
||||
limitManager.mergeBusyTimes(globalLimitManager);
|
||||
|
||||
if (bookingLimits) {
|
||||
for (const key of descendingLimitKeys) {
|
||||
@@ -1134,13 +1216,8 @@ const _getBusyTimesFromTeamLimitsForUsers = async (
|
||||
const userBusyTimes = busyTimes.filter((busyTime) => busyTime.userId === user.id);
|
||||
const limitManager = new LimitManager();
|
||||
|
||||
for (const busyTime of globalLimitManager.getBusyTimes()) {
|
||||
const start = dayjs(busyTime.start);
|
||||
const end = dayjs(busyTime.end);
|
||||
const unit = getUnitFromBusyTime(start, end);
|
||||
limitManager.mergeBusyTimes(globalLimitManager);
|
||||
|
||||
limitManager.addBusyTime(start, unit, timeZone);
|
||||
}
|
||||
const bookingLimitsParams = {
|
||||
bookings: userBusyTimes,
|
||||
bookingLimits,
|
||||
|
||||
@@ -209,6 +209,12 @@ async function getCroppedImg(
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Context is null, this should never happen.");
|
||||
|
||||
// Detect original image format from data URL
|
||||
const originalFormat =
|
||||
imageSrc.startsWith("data:image/jpeg") || imageSrc.startsWith("data:image/jpg")
|
||||
? "image/jpeg"
|
||||
: "image/png";
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
@@ -224,5 +230,6 @@ async function getCroppedImg(
|
||||
canvas.height
|
||||
);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
// Use original format with quality setting for JPEG
|
||||
return canvas.toDataURL(originalFormat, originalFormat === "image/jpeg" ? 0.6 : undefined);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Context is null, this should never happen.");
|
||||
|
||||
// Detect original image format from data URL
|
||||
const originalFormat =
|
||||
imageSrc.startsWith("data:image/jpeg") || imageSrc.startsWith("data:image/jpg")
|
||||
? "image/jpeg"
|
||||
: "image/png";
|
||||
|
||||
const maxSize = Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1;
|
||||
|
||||
@@ -216,7 +222,7 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
|
||||
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
|
||||
if (resizeRatio <= 0.75) {
|
||||
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
|
||||
return getCroppedImg(canvas.toDataURL("image/png"), {
|
||||
return getCroppedImg(canvas.toDataURL(originalFormat), {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
x: 0,
|
||||
@@ -224,5 +230,6 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
|
||||
});
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
// Use original format with quality setting for JPEG
|
||||
return canvas.toDataURL(originalFormat, originalFormat === "image/jpeg" ? 0.9 : undefined);
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ type IToast = {
|
||||
export const SuccessToast = ({ message, onClose, toastId }: IToast) => (
|
||||
<button
|
||||
className={classNames(
|
||||
"data-testid-toast-success bg-default dark:bg-inverted text-emphasis dark:text-inverted shadow-elevation-low border-subtle mb-2 flex h-auto space-x-2 rounded-lg border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
"data-testid-toast-success bg-default dark:bg-inverted text-emphasis dark:text-inverted shadow-elevation-low border-subtle mb-2 flex h-auto items-center justify-between space-x-2 rounded-lg border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
)}
|
||||
onClick={() => onClose(toastId)}>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
<span className="mt-1">
|
||||
<Icon name="check" className="size-4" />
|
||||
</span>
|
||||
<p data-testid="toast-success" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="x" className="h-4 w-4 hover:cursor-pointer" />
|
||||
<div className="flex items-center">
|
||||
<p data-testid="toast-success" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-1">
|
||||
<Icon name="x" className="size-4 hover:cursor-pointer" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -31,17 +33,19 @@ export const SuccessToast = ({ message, onClose, toastId }: IToast) => (
|
||||
export const ErrorToast = ({ message, onClose, toastId }: IToast) => (
|
||||
<button
|
||||
className={classNames(
|
||||
"animate-fade-in-up bg-semantic-error-subtle text-semantic-error shadow-elevation-low border-semantic-error-subtle mb-2 flex h-auto space-x-2 rounded-md border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
"animate-fade-in-up bg-semantic-error-subtle text-semantic-error shadow-elevation-low border-semantic-error-subtle mb-2 flex h-auto items-center justify-between space-x-2 rounded-md border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
)}
|
||||
onClick={() => onClose(toastId)}>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="info" className="text-semantic-error h-4 w-4" />
|
||||
<span className="mt-1">
|
||||
<Icon name="info" className="text-semantic-error size-4" />
|
||||
</span>
|
||||
<p data-testid="toast-error" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="x" className="text-semantic-error h-4 w-4 hover:cursor-pointer" />
|
||||
<div className="flex items-center">
|
||||
<p data-testid="toast-error" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-1">
|
||||
<Icon name="x" className="text-semantic-error size-4 hover:cursor-pointer" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -49,17 +53,19 @@ export const ErrorToast = ({ message, onClose, toastId }: IToast) => (
|
||||
export const WarningToast = ({ message, onClose, toastId }: IToast) => (
|
||||
<button
|
||||
className={classNames(
|
||||
"animate-fade-in-up bg-semantic-attention-subtle text-semantic-attention shadow-elevation-low border-semantic-attention-subtle mb-2 flex h-auto space-x-2 rounded-md border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
"animate-fade-in-up bg-semantic-attention-subtle text-semantic-attention shadow-elevation-low border-semantic-attention-subtle mb-2 flex h-auto items-center justify-between space-x-2 rounded-md border px-3 py-2.5 text-sm font-semibold rtl:space-x-reverse md:max-w-sm"
|
||||
)}
|
||||
onClick={() => onClose(toastId)}>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="info" className="text-semantic-attention h-4 w-4" />
|
||||
<span className="mt-1">
|
||||
<Icon name="info" className="text-semantic-attention size-4" />
|
||||
</span>
|
||||
<p data-testid="toast-warning" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
<span className="mt-0.5">
|
||||
<Icon name="x" className="text-semantic-attention h-4 w-4 hover:cursor-pointer" />
|
||||
<div className="flex items-center">
|
||||
<p data-testid="toast-warning" className="m-0 w-full text-left">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-1">
|
||||
<Icon name="x" className="text-semantic-attention size-4 hover:cursor-pointer" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -71,7 +77,6 @@ type ToastVariants = "success" | "warning" | "error";
|
||||
export function showToast(
|
||||
message: string,
|
||||
variant: ToastVariants,
|
||||
// Options or duration (duration for backwards compatibility reasons)
|
||||
options: number | ExternalToast = TOAST_VISIBLE_DURATION
|
||||
) {
|
||||
const _options: ExternalToast = typeof options === "number" ? { duration: options } : options;
|
||||
|
||||
Reference in New Issue
Block a user