Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ffebdba44 | ||
|
|
cc1101ac43 | ||
|
|
7a80a5f929 | ||
|
|
a27e1498b8 | ||
|
|
a697857fa0 | ||
|
|
e2f45bdf5a | ||
|
|
d2930bc243 | ||
|
|
b0b93f612e | ||
|
|
558c8be740 | ||
|
|
1aee1742d5 | ||
|
|
ab942ed4cd | ||
|
|
b5bec79354 | ||
|
|
b48d0e1f48 |
@@ -114,6 +114,7 @@ PLAIN_API_URL=https://api.plain.com/v1
|
||||
PLAIN_HMAC_SECRET_KEY=
|
||||
PLAIN_CHAT_ID=
|
||||
PLAIN_CHAT_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -0,0 +1,17 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import BookingDryRunSuccessView from "~/bookings/views/booking-dry-run-success-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("booking_dry_run_successful"),
|
||||
(t) => t("booking_dry_run_successful_description")
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
return <BookingDryRunSuccessView />;
|
||||
};
|
||||
|
||||
export default WithLayout({ ServerPage: Page });
|
||||
+164
-130
@@ -3,7 +3,7 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -70,148 +70,182 @@ interface PlainChatConfig {
|
||||
|
||||
const PlainChat = () => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const initConfig = async () => {
|
||||
if (!session?.user?.email) return;
|
||||
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
|
||||
const userEmail = session?.user?.email;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/plain-hash", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const isAppDomain = useMemo(() => {
|
||||
const restrictedPaths = process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || [];
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
|
||||
!restrictedPaths.some((path) => pathname?.startsWith(path.trim()))
|
||||
);
|
||||
}, [pathname]);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to generate hash: ${errorText}`);
|
||||
}
|
||||
const checkScreenSize = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const data = await response.json();
|
||||
const isSmall = window.innerWidth < 768;
|
||||
setIsSmallScreen(isSmall);
|
||||
|
||||
if (!data.hash || !data.email || !data.appId) {
|
||||
throw new Error("Missing required fields in API response");
|
||||
}
|
||||
if (isSmall && window.Plain) {
|
||||
const plainElement = document.querySelector("#plain-container");
|
||||
plainElement?.remove();
|
||||
window.Plain = undefined;
|
||||
} else if (!isSmall && window.Plain === undefined) {
|
||||
window.plainScriptLoaded?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const plainChatConfig: PlainChatConfig = {
|
||||
appId: data.appId,
|
||||
customerDetails: {
|
||||
email: data.email,
|
||||
shortName: data.shortName,
|
||||
fullName: data.fullName,
|
||||
emailHash: data.hash,
|
||||
chatAvatarUrl: data.chatAvatarUrl,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: "book",
|
||||
text: "Documentation",
|
||||
url: "https://cal.com/docs",
|
||||
},
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask the community",
|
||||
url: "https://github.com/calcom/cal.com/discussions",
|
||||
},
|
||||
],
|
||||
chatButtons: [
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask a question",
|
||||
type: "primary",
|
||||
},
|
||||
{
|
||||
icon: "bulb",
|
||||
text: "Send feedback",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Report an issue",
|
||||
type: "default",
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
type: "dropdown",
|
||||
placeholder: "Select severity...",
|
||||
options: [
|
||||
{
|
||||
icon: "support",
|
||||
text: "I'm unable to use the app",
|
||||
threadDetails: {
|
||||
severity: "critical",
|
||||
issueType: "critical",
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
priority: "u",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Major functionality degraded",
|
||||
threadDetails: {
|
||||
severity: "major",
|
||||
issueType: "major",
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
priority: "h",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "bug",
|
||||
text: "Minor annoyance",
|
||||
threadDetails: {
|
||||
severity: "minor",
|
||||
issueType: "minor",
|
||||
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
|
||||
priority: "l",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
entryPoint: {
|
||||
type: "chat",
|
||||
},
|
||||
hideBranding: true,
|
||||
theme: "auto",
|
||||
style: {
|
||||
brandColor: "#FFFFFF",
|
||||
launcherBackgroundColor: "#262626",
|
||||
launcherIconColor: "#FFFFFF",
|
||||
},
|
||||
position: {
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
},
|
||||
};
|
||||
const initConfig = useCallback(async () => {
|
||||
if (!userEmail) return;
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
|
||||
window.__PLAIN_CONFIG__ = plainChatConfig;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/plain-hash", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
setConfig(plainChatConfig);
|
||||
|
||||
if (pathname === "/event-types" && searchParams?.has("openPlain")) {
|
||||
const timer = setTimeout(() => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain Chat:", error);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to generate hash: ${errorText}`);
|
||||
}
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.hash || !data.email || !data.appId) {
|
||||
throw new Error("Missing required fields in API response");
|
||||
}
|
||||
|
||||
const plainChatConfig: PlainChatConfig = {
|
||||
appId: data.appId,
|
||||
customerDetails: {
|
||||
email: data.email,
|
||||
shortName: data.shortName,
|
||||
fullName: data.fullName,
|
||||
emailHash: data.hash,
|
||||
chatAvatarUrl: data.chatAvatarUrl,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: "book",
|
||||
text: "Documentation",
|
||||
url: "https://cal.com/docs",
|
||||
},
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask the community",
|
||||
url: "https://github.com/calcom/cal.com/discussions",
|
||||
},
|
||||
],
|
||||
chatButtons: [
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask a question",
|
||||
type: "primary",
|
||||
},
|
||||
{
|
||||
icon: "bulb",
|
||||
text: "Send feedback",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Report an issue",
|
||||
type: "default",
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
type: "dropdown",
|
||||
placeholder: "Select severity...",
|
||||
options: [
|
||||
{
|
||||
icon: "support",
|
||||
text: "I'm unable to use the app",
|
||||
threadDetails: {
|
||||
severity: "critical",
|
||||
issueType: "critical",
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
priority: "u",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Major functionality degraded",
|
||||
threadDetails: {
|
||||
severity: "major",
|
||||
issueType: "major",
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
priority: "h",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "bug",
|
||||
text: "Minor annoyance",
|
||||
threadDetails: {
|
||||
severity: "minor",
|
||||
issueType: "minor",
|
||||
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
|
||||
priority: "l",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
entryPoint: {
|
||||
type: "chat",
|
||||
},
|
||||
hideBranding: true,
|
||||
theme: "auto",
|
||||
style: {
|
||||
brandColor: "#FFFFFF",
|
||||
launcherBackgroundColor: "#262626",
|
||||
launcherIconColor: "#FFFFFF",
|
||||
},
|
||||
position: {
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
|
||||
window.__PLAIN_CONFIG__ = plainChatConfig;
|
||||
}
|
||||
|
||||
setConfig(plainChatConfig);
|
||||
|
||||
if (shouldOpenPlain) {
|
||||
const timer = setTimeout(() => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain Chat:", error);
|
||||
}
|
||||
}, [userEmail, shouldOpenPlain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppDomain) return;
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
initConfig();
|
||||
}, [session, pathname, searchParams]);
|
||||
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
|
||||
|
||||
const plainChatScript = `
|
||||
window.plainScriptLoaded = function() {
|
||||
@@ -225,7 +259,7 @@ const PlainChat = () => {
|
||||
}
|
||||
`;
|
||||
|
||||
if (!config) return null;
|
||||
if (!isAppDomain || isSmallScreen || !config || typeof window === "undefined") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
export default function DryRunSuccessful() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const sampleBookingInfo = {
|
||||
startTime: "2024-01-20T10:00:00Z",
|
||||
endTime: "2024-01-20T11:00:00Z",
|
||||
title: "Sample Meeting",
|
||||
description: "This is a sample dry run booking",
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
name: "Sample Attendee",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
],
|
||||
user: {
|
||||
name: "Sample Host",
|
||||
email: "host@example.com",
|
||||
},
|
||||
location: "Zoom Meeting",
|
||||
status: "ACCEPTED",
|
||||
uid: "sample-uid",
|
||||
};
|
||||
|
||||
const sampleEventType = {
|
||||
title: "Sample Event Type",
|
||||
length: 60,
|
||||
eventName: "Sample Meeting",
|
||||
description: "This is a sample event type for dry run",
|
||||
requiresConfirmation: false,
|
||||
recurringEvent: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="bg-default m-auto rounded-md p-10 text-center">
|
||||
<div className="bg-success mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="check" className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-emphasis mb-4 text-2xl font-medium">{t("booking_dry_run_successful")}</h1>
|
||||
<p className="text-default mb-8 max-w-2xl text-sm">{t("booking_dry_run_successful_description")}</p>
|
||||
|
||||
{/* <div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6">{sampleBookingInfo.title}</div>
|
||||
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6">{new Date(sampleBookingInfo.startTime).toLocaleString()}</div>
|
||||
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span className="mr-2">{sampleBookingInfo.user.name}</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
<p className="text-default">{sampleBookingInfo.user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>{sampleBookingInfo.attendees[0].name}</p>
|
||||
<p>{sampleBookingInfo.attendees[0].email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mb-6">{sampleBookingInfo.location}</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -292,10 +292,6 @@ const nextConfig = {
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router/:path*",
|
||||
destination: "/apps/routing-forms/router/:path*",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.8.8",
|
||||
"version": "4.8.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getServerSideProps as _getServerSideProps } from "../../server/lib/router/getServerSideProps";
|
||||
|
||||
export { default } from "./index";
|
||||
// We are not using withEmbedSsr here because _getServerSideProps already has the logic to append /embed to the redirect URL and it ends up as /embed/embed
|
||||
// TODO: We should use withEmbedSsr here and remove the logic from _getServerSideProps
|
||||
export const getServerSideProps = _getServerSideProps;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Head from "next/head";
|
||||
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
import { getServerSideProps } from "./getServerSideProps";
|
||||
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
|
||||
|
||||
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
|
||||
return (
|
||||
@@ -2216,8 +2216,8 @@
|
||||
"org_no_teams_yet": "هذه المنظمة ليس لديها فرق بعد",
|
||||
"org_no_teams_yet_description": "إذا كنت مسؤول، تأكد من إنشاء فرق لعرضها هنا.",
|
||||
"set_up": "الإعداد",
|
||||
"my_profile": "الملف الشخصي",
|
||||
"my_settings": "الإعدادات",
|
||||
"my_profile": "ملفي الشخصي",
|
||||
"my_settings": "إعداداتي",
|
||||
"crm": "CRM",
|
||||
"messaging": "المراسلة",
|
||||
"sender_id_info": "الاسم أو الرقم الظاهر على أنه مرسل الرسالة النصية القصيرة (بعض البلدان لا تسمح بمعرفات المرسل الأبجدية الرقمية)",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "اسم المالك المراد تغييره",
|
||||
"no_filter_set": "لم يتم تعيين أي تصفية",
|
||||
"booking_dry_run_successful": "تم إجراء المحاكاة التجريبية للحجز بنجاح",
|
||||
"booking_dry_run_successful_description": "تم إكمال التشغيل التجريبي بنجاح.",
|
||||
"dont_have_access_this_page": "ليس لديك صلاحية الوصول إلى هذه الصفحة",
|
||||
"you_need_admin_or_owner_privileges_to_access": "لرؤية هذه الصفحة أو التفاعل معها، يجب أن تكون لديك صلاحيات المدير أو المالك",
|
||||
"field_type": "نوع الحقل",
|
||||
|
||||
@@ -2216,8 +2216,8 @@
|
||||
"org_no_teams_yet": "Bu təşkilatda hələ komanda yoxdur",
|
||||
"org_no_teams_yet_description": "Əgər siz administratorsunuzsa, burada göstərmək üçün komandalar yaratdığınızdan əmin olun.",
|
||||
"set_up": "Quraşdır",
|
||||
"my_profile": "Profilim",
|
||||
"my_settings": "Ayarlarım",
|
||||
"my_profile": "Mənim profilim",
|
||||
"my_settings": "Mənim tənzimləmələrim",
|
||||
"crm": "CRM",
|
||||
"messaging": "Mesajlaşma",
|
||||
"sender_id_info": "SMS göndərən kimi göstərilən ad və ya nömrə (bəzi ölkələrdə alfasayısal göndərən ID-lərinə icazə verilmir)",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Dəyişdiriləcək sahibin adı",
|
||||
"no_filter_set": "Filtr təyin edilməyib",
|
||||
"booking_dry_run_successful": "Rezervasiya test yoxlaması uğurlu oldu",
|
||||
"booking_dry_run_successful_description": "Test yoxlaması uğurla başa çatdı.",
|
||||
"dont_have_access_this_page": "Bu səhifəyə girişiniz yoxdur",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Bu səhifəni görmək və ya onunla qarşılıqlı əlaqədə olmaq üçün admin və ya sahib imtiyazlarına malik olmalısınız",
|
||||
"field_type": "Sahə növü",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Име на собственик за промяна",
|
||||
"no_filter_set": "Няма зададен филтър",
|
||||
"booking_dry_run_successful": "Тестовото резервиране е успешно",
|
||||
"booking_dry_run_successful_description": "Тестовото изпълнение завърши успешно.",
|
||||
"dont_have_access_this_page": "Нямате достъп до тази страница",
|
||||
"you_need_admin_or_owner_privileges_to_access": "За да видите или взаимодействате с тази страница, трябва да имате администраторски права или да сте собственик",
|
||||
"field_type": "Тип на полето",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nom del propietari a canviar",
|
||||
"no_filter_set": "Cap filtre establert",
|
||||
"booking_dry_run_successful": "La prova de reserva s'ha realitzat correctament",
|
||||
"booking_dry_run_successful_description": "La prova de funcionament s'ha completat correctament.",
|
||||
"dont_have_access_this_page": "No tens accés a aquesta pàgina",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Per veure o interactuar amb aquesta pàgina, has de tenir privilegis d'administrador o propietari",
|
||||
"field_type": "Tipus de camp",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Jméno vlastníka ke změně",
|
||||
"no_filter_set": "Není nastaven žádný filtr",
|
||||
"booking_dry_run_successful": "Testovací rezervace proběhla úspěšně",
|
||||
"booking_dry_run_successful_description": "Testovací běh byl úspěšně dokončen.",
|
||||
"dont_have_access_this_page": "K této stránce nemáte přístup",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Pro zobrazení nebo interakci s touto stránkou musíte mít administrátorská nebo vlastnická práva",
|
||||
"field_type": "Typ pole",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Ejernavn der skal ændres",
|
||||
"no_filter_set": "Intet filter indstillet",
|
||||
"booking_dry_run_successful": "Booking-testkørsel gennemført",
|
||||
"booking_dry_run_successful_description": "Testkørslen blev gennemført med succes.",
|
||||
"dont_have_access_this_page": "Du har ikke adgang til denne side",
|
||||
"you_need_admin_or_owner_privileges_to_access": "For at kunne se eller interagere med denne side, skal du have administrator- eller ejerrettigheder",
|
||||
"field_type": "Felttype",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Zu ändernder Besitzername",
|
||||
"no_filter_set": "Kein Filter gesetzt",
|
||||
"booking_dry_run_successful": "Buchungs-Testlauf erfolgreich",
|
||||
"booking_dry_run_successful_description": "Der Testlauf wurde erfolgreich abgeschlossen.",
|
||||
"dont_have_access_this_page": "Sie haben keinen Zugriff auf diese Seite",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Um diese Seite anzuzeigen oder mit ihr zu interagieren, benötigen Sie Administrator- oder Eigentümerrechte",
|
||||
"field_type": "Feldtyp",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Όνομα κατόχου προς αλλαγή",
|
||||
"no_filter_set": "Δεν έχει οριστεί φίλτρο",
|
||||
"booking_dry_run_successful": "Η δοκιμαστική κράτηση ολοκληρώθηκε με επιτυχία",
|
||||
"booking_dry_run_successful_description": "Η δοκιμαστική εκτέλεση ολοκληρώθηκε με επιτυχία.",
|
||||
"dont_have_access_this_page": "Δεν έχετε πρόσβαση σε αυτή τη σελίδα",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Για να δείτε ή να αλληλεπιδράσετε με αυτή τη σελίδα, πρέπει να έχετε δικαιώματα διαχειριστή ή ιδιοκτήτη",
|
||||
"field_type": "Τύπος πεδίου",
|
||||
|
||||
@@ -2815,6 +2815,7 @@
|
||||
"salesforce_owner_name_to_change": "Owner name to change",
|
||||
"no_filter_set": "No filter set",
|
||||
"booking_dry_run_successful": "Booking dry run successful",
|
||||
"booking_dry_run_successful_description": "The dry run was completed successfully.",
|
||||
"dont_have_access_this_page": "You don't have access to this page",
|
||||
"you_need_admin_or_owner_privileges_to_access": "In order to view or interact with this page, you must have admin or owner privileges",
|
||||
"field_type": "Field type",
|
||||
|
||||
@@ -2216,8 +2216,8 @@
|
||||
"org_no_teams_yet": "Esta organización aún no tiene equipos",
|
||||
"org_no_teams_yet_description": "Si eres administrador, asegúrate de crear equipos para que se muestren aquí.",
|
||||
"set_up": "Configurar",
|
||||
"my_profile": "Mi Perfil",
|
||||
"my_settings": "Mis Configuraciones",
|
||||
"my_profile": "Mi perfil",
|
||||
"my_settings": "Mi configuración",
|
||||
"crm": "CRM",
|
||||
"messaging": "Mensajería",
|
||||
"sender_id_info": "Nombre o número mostrado como el remitente de un SMS (algunos países no permiten IDs de remitente alfanuméricos)",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nombre del propietario a cambiar",
|
||||
"no_filter_set": "Sin filtros establecidos",
|
||||
"booking_dry_run_successful": "Prueba de reserva exitosa",
|
||||
"booking_dry_run_successful_description": "La prueba de simulación se completó exitosamente.",
|
||||
"dont_have_access_this_page": "No tienes acceso a esta página",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Para ver o interactuar con esta página, debes tener privilegios de administrador o propietario",
|
||||
"field_type": "Tipo de campo",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nombre del propietario a cambiar",
|
||||
"no_filter_set": "Ningún filtro establecido",
|
||||
"booking_dry_run_successful": "Prueba de reserva exitosa",
|
||||
"booking_dry_run_successful_description": "La prueba de simulación se completó con éxito.",
|
||||
"dont_have_access_this_page": "No tienes acceso a esta página",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Para ver o interactuar con esta página, debes tener privilegios de administrador o propietario",
|
||||
"field_type": "Tipo de campo",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Muudetava omaniku nimi",
|
||||
"no_filter_set": "Filter määramata",
|
||||
"booking_dry_run_successful": "Broneeringu proovikäivitus õnnestus",
|
||||
"booking_dry_run_successful_description": "Proovijooks lõppes edukalt.",
|
||||
"dont_have_access_this_page": "Teil ei ole sellele lehele juurdepääsu",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Selle lehe vaatamiseks või sellega suhtlemiseks peate omama administraatori või omaniku õigusi",
|
||||
"field_type": "Välja tüüp",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Aldatu beharreko jabearen izena",
|
||||
"no_filter_set": "Ez dago iragazkirik ezarrita",
|
||||
"booking_dry_run_successful": "Erreserben proba arrakastatsua",
|
||||
"booking_dry_run_successful_description": "Proba exekuzioa arrakastaz burutu da.",
|
||||
"dont_have_access_this_page": "Ez duzu orri honetarako sarbiderik",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Orri hau ikusteko edo erabiltzeko, administratzaile edo jabe baimenak izan behar dituzu",
|
||||
"field_type": "Eremu mota",
|
||||
|
||||
@@ -2216,8 +2216,8 @@
|
||||
"org_no_teams_yet": "Tällä organisaatiolla ei ole vielä tiimejä",
|
||||
"org_no_teams_yet_description": "Jos olet järjestelmänvalvoja, muista luoda tiimejä, jotta ne näkyvät täällä.",
|
||||
"set_up": "Määritä",
|
||||
"my_profile": "Oma profiili",
|
||||
"my_settings": "Omat asetukset",
|
||||
"my_profile": "Profiilini",
|
||||
"my_settings": "Asetukseni",
|
||||
"crm": "CRM",
|
||||
"messaging": "Viestintä",
|
||||
"sender_id_info": "Nimi tai numero, joka näkyy tekstiviestin lähettäjänä (kaikki maat eivät salli aakkosnumeerisia lähettäjätunnisteita)",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Muutettavan omistajan nimi",
|
||||
"no_filter_set": "Ei suodatinta määritetty",
|
||||
"booking_dry_run_successful": "Varauksen testaus onnistui",
|
||||
"booking_dry_run_successful_description": "Testausajo suoritettiin onnistuneesti.",
|
||||
"dont_have_access_this_page": "Sinulla ei ole pääsyä tälle sivulle",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Nähdäksesi tai käyttääksesi tätä sivua sinulla täytyy olla ylläpitäjän tai omistajan oikeudet",
|
||||
"field_type": "Kenttätyyppi",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nom du propriétaire à modifier",
|
||||
"no_filter_set": "Aucun filtre défini",
|
||||
"booking_dry_run_successful": "Test de réservation réussi",
|
||||
"booking_dry_run_successful_description": "Le test à blanc a été effectué avec succès.",
|
||||
"dont_have_access_this_page": "Vous n'avez pas accès à cette page",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Pour voir ou interagir avec cette page, vous devez avoir des privilèges d'administrateur ou de propriétaire",
|
||||
"field_type": "Type de champ",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "שם הבעלים לשינוי",
|
||||
"no_filter_set": "לא הוגדר סינון",
|
||||
"booking_dry_run_successful": "הרצת הניסיון של ההזמנה הצליחה",
|
||||
"booking_dry_run_successful_description": "הריצה הניסיונית הושלמה בהצלחה.",
|
||||
"dont_have_access_this_page": "אין לך גישה לדף זה",
|
||||
"you_need_admin_or_owner_privileges_to_access": "כדי לצפות או לפעול בדף זה, עליך להיות בעל הרשאות מנהל או בעלים",
|
||||
"field_type": "סוג שדה",
|
||||
|
||||
@@ -2216,7 +2216,7 @@
|
||||
"org_no_teams_yet": "Ennek a szervezetnek még nincsenek csapatai",
|
||||
"org_no_teams_yet_description": "Ha Ön rendszergazda, feltétlenül hozzon létre csapatokat, amelyek itt megjelennek.",
|
||||
"set_up": "Beállít",
|
||||
"my_profile": "A profilom",
|
||||
"my_profile": "Profilom",
|
||||
"my_settings": "Beállításaim",
|
||||
"crm": "CRM",
|
||||
"messaging": "Üzenetküldés",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Módosítandó tulajdonos neve",
|
||||
"no_filter_set": "Nincs beállított szűrő",
|
||||
"booking_dry_run_successful": "A foglalás tesztfuttatása sikeres",
|
||||
"booking_dry_run_successful_description": "A teszt futtatás sikeresen befejeződött.",
|
||||
"dont_have_access_this_page": "Nincs hozzáférése ehhez az oldalhoz",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Az oldal megtekintéséhez vagy használatához adminisztrátori vagy tulajdonosi jogosultság szükséges",
|
||||
"field_type": "Mező típusa",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nome del proprietario da modificare",
|
||||
"no_filter_set": "Nessun filtro impostato",
|
||||
"booking_dry_run_successful": "Test di prenotazione completato con successo",
|
||||
"booking_dry_run_successful_description": "Il test di prova è stato completato con successo.",
|
||||
"dont_have_access_this_page": "Non hai accesso a questa pagina",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Per visualizzare o interagire con questa pagina, devi avere privilegi di amministratore o proprietario",
|
||||
"field_type": "Tipo di campo",
|
||||
|
||||
@@ -2216,7 +2216,7 @@
|
||||
"org_no_teams_yet": "この組織にはまだチームがありません",
|
||||
"org_no_teams_yet_description": "管理者である場合には、ここに表示されるチームを必ず作成してください。",
|
||||
"set_up": "設定",
|
||||
"my_profile": "プロフィール",
|
||||
"my_profile": "マイプロフィール",
|
||||
"my_settings": "設定",
|
||||
"crm": "CRM",
|
||||
"messaging": "メッセージング",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "変更する所有者名",
|
||||
"no_filter_set": "フィルター未設定",
|
||||
"booking_dry_run_successful": "予約のテストが正常に完了しました",
|
||||
"booking_dry_run_successful_description": "テスト実行は正常に完了しました。",
|
||||
"dont_have_access_this_page": "このページにアクセスする権限がありません",
|
||||
"you_need_admin_or_owner_privileges_to_access": "このページを表示または操作するには、管理者または所有者の権限が必要です",
|
||||
"field_type": "フィールドタイプ",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "ឈ្មោះម្ចាស់ដែលត្រូវផ្លាស់ប្តូរ",
|
||||
"no_filter_set": "មិនមានការកំណត់តម្រង",
|
||||
"booking_dry_run_successful": "ការសាកល្បងការកក់បានជោគជ័យ",
|
||||
"booking_dry_run_successful_description": "ការសាកល្បងត្រូវបានបញ្ចប់ដោយជោគជ័យ។",
|
||||
"dont_have_access_this_page": "អ្នកមិនមានសិទ្ធិចូលប្រើទំព័រនេះទេ",
|
||||
"you_need_admin_or_owner_privileges_to_access": "ដើម្បីមើល ឬប្រើប្រាស់ទំព័រនេះ អ្នកត្រូវតែមានសិទ្ធិជាអ្នកគ្រប់គ្រង ឬម្ចាស់",
|
||||
"field_type": "ប្រភេទវាល",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "변경할 소유자 이름",
|
||||
"no_filter_set": "설정된 필터 없음",
|
||||
"booking_dry_run_successful": "예약 테스트 실행 성공",
|
||||
"booking_dry_run_successful_description": "테스트 실행이 성공적으로 완료되었습니다.",
|
||||
"dont_have_access_this_page": "이 페이지에 접근할 수 없습니다",
|
||||
"you_need_admin_or_owner_privileges_to_access": "이 페이지를 보거나 상호작용하려면 관리자 또는 소유자 권한이 필요합니다",
|
||||
"field_type": "필드 유형",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Eigenaarsnaam om te wijzigen",
|
||||
"no_filter_set": "Geen filter ingesteld",
|
||||
"booking_dry_run_successful": "Boeking test succesvol",
|
||||
"booking_dry_run_successful_description": "De testrun is succesvol afgerond.",
|
||||
"dont_have_access_this_page": "Je hebt geen toegang tot deze pagina",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Om deze pagina te bekijken of ermee te communiceren, moet je over admin- of eigenaarsrechten beschikken",
|
||||
"field_type": "Veldtype",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Eiernavn som skal endres",
|
||||
"no_filter_set": "Ingen filter er satt",
|
||||
"booking_dry_run_successful": "Test av booking var vellykket",
|
||||
"booking_dry_run_successful_description": "Testkjøringen ble fullført vellykket.",
|
||||
"dont_have_access_this_page": "Du har ikke tilgang til denne siden",
|
||||
"you_need_admin_or_owner_privileges_to_access": "For å se eller bruke denne siden, må du ha administrator- eller eierrettigheter",
|
||||
"field_type": "Felttype",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nazwa właściciela do zmiany",
|
||||
"no_filter_set": "Brak ustawionego filtru",
|
||||
"booking_dry_run_successful": "Test rezerwacji zakończony pomyślnie",
|
||||
"booking_dry_run_successful_description": "Test został zakończony pomyślnie.",
|
||||
"dont_have_access_this_page": "Nie masz dostępu do tej strony",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Aby wyświetlić lub korzystać z tej strony, musisz mieć uprawnienia administratora lub właściciela",
|
||||
"field_type": "Typ pola",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nome do proprietário a ser alterado",
|
||||
"no_filter_set": "Nenhum filtro definido",
|
||||
"booking_dry_run_successful": "Simulação de agendamento bem-sucedida",
|
||||
"booking_dry_run_successful_description": "O teste simulado foi concluído com sucesso.",
|
||||
"dont_have_access_this_page": "Você não tem acesso a esta página",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Para visualizar ou interagir com esta página, você deve ter privilégios de administrador ou proprietário",
|
||||
"field_type": "Tipo de campo",
|
||||
|
||||
@@ -2216,8 +2216,8 @@
|
||||
"org_no_teams_yet": "Esta organização ainda não tem equipas",
|
||||
"org_no_teams_yet_description": "Se é um administrador, deve criar equipas para serem apresentadas aqui.",
|
||||
"set_up": "Configurar",
|
||||
"my_profile": "O meu perfil",
|
||||
"my_settings": "As minhas definições",
|
||||
"my_profile": "Meu perfil",
|
||||
"my_settings": "Minhas configurações",
|
||||
"crm": "CRM",
|
||||
"messaging": "Mensagens",
|
||||
"sender_id_info": "Nome ou número mostrado como remetente de um SMS (alguns países não permitem identificadores alfanuméricos de remetentes)",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Nome do proprietário a alterar",
|
||||
"no_filter_set": "Nenhum filtro definido",
|
||||
"booking_dry_run_successful": "Teste de agendamento realizado com sucesso",
|
||||
"booking_dry_run_successful_description": "O teste simulado foi concluído com sucesso.",
|
||||
"dont_have_access_this_page": "Você não tem acesso a esta página",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Para visualizar ou interagir com esta página, você deve ter privilégios de administrador ou proprietário",
|
||||
"field_type": "Tipo de campo",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Numele proprietarului de schimbat",
|
||||
"no_filter_set": "Niciun filtru setat",
|
||||
"booking_dry_run_successful": "Simulare programare reușită",
|
||||
"booking_dry_run_successful_description": "Testul de simulare a fost finalizat cu succes.",
|
||||
"dont_have_access_this_page": "Nu aveți acces la această pagină",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Pentru a vizualiza sau interacționa cu această pagină, trebuie să aveți privilegii de administrator sau proprietar",
|
||||
"field_type": "Tip câmp",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Имя владельца для изменения",
|
||||
"no_filter_set": "Фильтр не установлен",
|
||||
"booking_dry_run_successful": "Тестовое бронирование выполнено успешно",
|
||||
"booking_dry_run_successful_description": "Пробный запуск успешно завершен.",
|
||||
"dont_have_access_this_page": "У вас нет доступа к этой странице",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Для просмотра или взаимодействия с этой страницей необходимы права администратора или владельца",
|
||||
"field_type": "Тип поля",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Meno vlastníka na zmenu",
|
||||
"no_filter_set": "Nie je nastavený žiadny filter",
|
||||
"booking_dry_run_successful": "Testovacia rezervácia prebehla úspešne",
|
||||
"booking_dry_run_successful_description": "Testovací beh bol úspešne dokončený.",
|
||||
"dont_have_access_this_page": "Nemáte prístup na túto stránku",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Na zobrazenie alebo interakciu s touto stránkou musíte mať administrátorské alebo vlastnícke práva",
|
||||
"field_type": "Typ poľa",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Ime vlasnika koje treba promeniti",
|
||||
"no_filter_set": "Nije postavljen filter",
|
||||
"booking_dry_run_successful": "Test rezervacije uspešno završen",
|
||||
"booking_dry_run_successful_description": "Test je uspešno završen.",
|
||||
"dont_have_access_this_page": "Nemate pristup ovoj stranici",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Da biste pregledali ili koristili ovu stranicu, morate imati administratorske ili vlasničke privilegije",
|
||||
"field_type": "Tip polja",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Ägarnamn att ändra",
|
||||
"no_filter_set": "Inget filter inställt",
|
||||
"booking_dry_run_successful": "Testbokning genomförd",
|
||||
"booking_dry_run_successful_description": "Testkörningen slutfördes framgångsrikt.",
|
||||
"dont_have_access_this_page": "Du har inte åtkomst till den här sidan",
|
||||
"you_need_admin_or_owner_privileges_to_access": "För att visa eller interagera med den här sidan måste du ha administratörs- eller ägarprivilegier",
|
||||
"field_type": "Fälttyp",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Değiştirilecek sahip adı",
|
||||
"no_filter_set": "Filtre ayarlanmadı",
|
||||
"booking_dry_run_successful": "Rezervasyon test çalıştırması başarılı",
|
||||
"booking_dry_run_successful_description": "Test çalıştırması başarıyla tamamlandı.",
|
||||
"dont_have_access_this_page": "Bu sayfaya erişiminiz yok",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Bu sayfayı görüntülemek veya etkileşimde bulunmak için yönetici veya sahip ayrıcalıklarına sahip olmalısınız",
|
||||
"field_type": "Alan türü",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Ім'я власника для зміни",
|
||||
"no_filter_set": "Фільтр не встановлено",
|
||||
"booking_dry_run_successful": "Тестове бронювання успішне",
|
||||
"booking_dry_run_successful_description": "Тестовий запуск успішно завершено.",
|
||||
"dont_have_access_this_page": "Ви не маєте доступу до цієї сторінки",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Щоб переглядати або взаємодіяти з цією сторінкою, ви повинні мати права адміністратора або власника",
|
||||
"field_type": "Тип поля",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "Tên chủ sở hữu cần thay đổi",
|
||||
"no_filter_set": "Chưa đặt bộ lọc",
|
||||
"booking_dry_run_successful": "Chạy thử đặt lịch thành công",
|
||||
"booking_dry_run_successful_description": "Quá trình chạy thử đã hoàn tất thành công.",
|
||||
"dont_have_access_this_page": "Bạn không có quyền truy cập trang này",
|
||||
"you_need_admin_or_owner_privileges_to_access": "Để xem hoặc tương tác với trang này, bạn cần có quyền quản trị viên hoặc chủ sở hữu",
|
||||
"field_type": "Loại trường",
|
||||
|
||||
@@ -2216,7 +2216,7 @@
|
||||
"org_no_teams_yet": "此组织尚无团队",
|
||||
"org_no_teams_yet_description": "如果您是管理员,请务必创建要在此处显示的团队。",
|
||||
"set_up": "设置",
|
||||
"my_profile": "我的个人资料",
|
||||
"my_profile": "我的资料",
|
||||
"my_settings": "我的设置",
|
||||
"crm": "CRM",
|
||||
"messaging": "消息",
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "要更改的所有者名称",
|
||||
"no_filter_set": "未设置筛选条件",
|
||||
"booking_dry_run_successful": "预订测试成功",
|
||||
"booking_dry_run_successful_description": "测试运行已成功完成。",
|
||||
"dont_have_access_this_page": "您无权访问此页面",
|
||||
"you_need_admin_or_owner_privileges_to_access": "要查看或操作此页面,您必须拥有管理员或所有者权限",
|
||||
"field_type": "字段类型",
|
||||
|
||||
@@ -2811,6 +2811,7 @@
|
||||
"salesforce_owner_name_to_change": "要更改的擁有者名稱",
|
||||
"no_filter_set": "未設定篩選條件",
|
||||
"booking_dry_run_successful": "預約測試成功",
|
||||
"booking_dry_run_successful_description": "測試運行已成功完成。",
|
||||
"dont_have_access_this_page": "您無權存取此頁面",
|
||||
"you_need_admin_or_owner_privileges_to_access": "若要查看或使用此頁面,您必須具有管理員或擁有者權限",
|
||||
"field_type": "欄位類型",
|
||||
|
||||
+42
-68
@@ -1,48 +1,42 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { stringify } from "querystring";
|
||||
import z from "zod";
|
||||
|
||||
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader";
|
||||
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 { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
|
||||
import { isAuthorizedToViewTheForm } from "@calcom/app-store/routing-forms/pages/routing-link/getServerSideProps";
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "../../getEventTypeRedirectUrl";
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import { findMatchingRoute } from "../../lib/processRoute";
|
||||
import { substituteVariables } from "../../lib/substituteVariables";
|
||||
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
|
||||
import type { FormResponse } from "../../types/types";
|
||||
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
|
||||
import { getUrlSearchParamsToForward } from "../routing-link/getUrlSearchParamsToForward";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
form: z.string(),
|
||||
slug: z.string(),
|
||||
pages: z.array(z.string()),
|
||||
})
|
||||
.catchall(z.string().or(z.array(z.string())));
|
||||
|
||||
function getNamedParams(params: { appPages: string[] } | undefined) {
|
||||
const [embed] = params?.appPages || [];
|
||||
return {
|
||||
// There might not be item at index 0, so explicit assertion is needed
|
||||
embed: embed as typeof embed | undefined,
|
||||
};
|
||||
function hasEmbedPath(pathWithQuery: string) {
|
||||
const onlyPath = pathWithQuery.split("?")[0];
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
export const getServerSideProps = async function getServerSideProps(
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma
|
||||
) {
|
||||
export const getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const queryParsed = querySchema.safeParse(context.query);
|
||||
const { embed } = getNamedParams(context.params);
|
||||
const isEmbed = hasEmbedPath(context.req.url || "");
|
||||
const pageProps = {
|
||||
isEmbed: !!embed,
|
||||
isEmbed,
|
||||
};
|
||||
|
||||
if (!queryParsed.success) {
|
||||
@@ -51,42 +45,16 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
|
||||
|
||||
// Known params reserved by Cal.com are form, embed, layout. We should exclude all of them.
|
||||
const { form: formId, ...fieldsResponses } = queryParsed.data;
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
movedToProfileId: true,
|
||||
metadata: true,
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
let timeTaken: Record<string, number | null> = {};
|
||||
|
||||
const formQueryStart = performance.now();
|
||||
const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
||||
timeTaken.formQuery = performance.now() - formQueryStart;
|
||||
|
||||
if (!form) {
|
||||
return {
|
||||
@@ -95,20 +63,24 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
}
|
||||
|
||||
const { UserRepository } = await import("@calcom/lib/server/repository/user");
|
||||
const profileEnrichmentStart = performance.now();
|
||||
const formWithUserProfile = {
|
||||
...form,
|
||||
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
|
||||
};
|
||||
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
|
||||
|
||||
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
|
||||
if (!isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain })) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const getSerializableFormStart = performance.now();
|
||||
const serializableForm = await getSerializableForm({
|
||||
form: enrichFormWithMigrationData(formWithUserProfile),
|
||||
});
|
||||
timeTaken.getSerializableForm = performance.now() - getSerializableFormStart;
|
||||
|
||||
const response: FormResponse = {};
|
||||
if (!serializableForm.fields) {
|
||||
@@ -131,18 +103,13 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
|
||||
const decidedAction = matchingRoute.action;
|
||||
|
||||
const { createContext } = await import("@calcom/trpc/server/createContext");
|
||||
const ctx = await createContext(context);
|
||||
|
||||
const { default: trpcRouter } = await import("@calcom/app-store/routing-forms/trpc/_router");
|
||||
const caller = trpcRouter.createCaller(ctx);
|
||||
const { v4: uuidv4 } = await import("uuid");
|
||||
let teamMembersMatchingAttributeLogic = null;
|
||||
let formResponseId = null;
|
||||
let attributeRoutingConfig = null;
|
||||
try {
|
||||
const result = await caller.public.response({
|
||||
formId: form.id,
|
||||
const result = await handleResponse({
|
||||
form: serializableForm,
|
||||
formFillerId: uuidv4(),
|
||||
response: response,
|
||||
chosenRouteId: matchingRoute.id,
|
||||
@@ -150,6 +117,10 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
||||
formResponseId = result.formResponse.id;
|
||||
attributeRoutingConfig = result.attributeRoutingConfig;
|
||||
timeTaken = {
|
||||
...timeTaken,
|
||||
...result.timeTaken,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof TRPCError) {
|
||||
return {
|
||||
@@ -162,6 +133,9 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: To be done using sentry tracing
|
||||
console.log("Server-Timing", getServerTimingHeader(timeTaken));
|
||||
|
||||
//TODO: Maybe take action after successful mutation
|
||||
if (decidedAction.type === "customPageMessage") {
|
||||
return {
|
||||
@@ -23,6 +23,9 @@
|
||||
},
|
||||
"pages/api/trpc/appRoutingForms/[trpc].ts": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/router/embed.tsx": {
|
||||
"memory": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2218,8 +2218,8 @@ checksums:
|
||||
org_no_teams_yet: 12e194480db8d89f2d1d49849e006d0f
|
||||
org_no_teams_yet_description: a33626f45a1e7a480855e2d58557899b
|
||||
set_up: b9fd81483112f7f0f8ca6c8193560618
|
||||
my_profile: 0b50373a58d99e69ccabd0a73872bbb7
|
||||
my_settings: 4ddfdf2f93534c797b7671e2f2f7ea10
|
||||
my_profile: 3400964aa3443f81aa521c664c21aeaf
|
||||
my_settings: 5a5728b1f142aaf81af5b56dda752ada
|
||||
crm: 441decfd55814370f4ef962cb9faaf4b
|
||||
messaging: 84ef090280dd156c702e414598704083
|
||||
sender_id_info: a9932f818e07563027b457f42746f6b0
|
||||
@@ -2813,6 +2813,7 @@ checksums:
|
||||
salesforce_owner_name_to_change: 000522ab557b02cfe4a5d49787259ef9
|
||||
no_filter_set: 5167f195fa1a278506d613d550b578f0
|
||||
booking_dry_run_successful: 9f3d6faa29b1a777f3f2610367da5586
|
||||
booking_dry_run_successful_description: 0e0a875cc67bef047bfd0033b1ac4498
|
||||
dont_have_access_this_page: c2341fe363c89498d3d0deb64f3977b1
|
||||
you_need_admin_or_owner_privileges_to_access: 7f032471f243015def700f16681274b2
|
||||
field_type: 7444da76fa3572d47ea5b5d86c50f11f
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Settings } from "react-awesome-query-builder";
|
||||
import { describe, it, vi, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ConfigFor,
|
||||
withRaqbSettingsAndWidgets,
|
||||
} from "../components/react-awesome-query-builder/config/uiConfig";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../components/react-awesome-query-builder/widgets", () => ({
|
||||
default: {
|
||||
TextWidget: vi.fn(),
|
||||
TextAreaWidget: vi.fn(),
|
||||
MultiSelectWidget: vi.fn(),
|
||||
SelectWidget: vi.fn(),
|
||||
NumberWidget: vi.fn(),
|
||||
FieldSelect: vi.fn(),
|
||||
Conjs: vi.fn(),
|
||||
Button: vi.fn(),
|
||||
ButtonGroup: vi.fn(),
|
||||
Provider: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui", () => ({
|
||||
EmailField: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("uiConfig", () => {
|
||||
const mockConfig = {
|
||||
widgets: {
|
||||
text: {
|
||||
type: "text",
|
||||
},
|
||||
textarea: {
|
||||
type: "textarea",
|
||||
},
|
||||
number: {
|
||||
type: "number",
|
||||
},
|
||||
multiselect: {
|
||||
type: "multiselect",
|
||||
},
|
||||
select: {
|
||||
type: "select",
|
||||
},
|
||||
phone: {
|
||||
type: "phone",
|
||||
},
|
||||
email: {
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
settings: {} as Settings,
|
||||
};
|
||||
|
||||
describe("withRaqbSettingsAndWidgets", () => {
|
||||
it("should add factory functions to all widgets", () => {
|
||||
const result = withRaqbSettingsAndWidgets({
|
||||
config: mockConfig,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
// Verify each widget has a factory function
|
||||
expect(result.widgets.text).toHaveProperty("factory");
|
||||
expect(result.widgets.textarea).toHaveProperty("factory");
|
||||
expect(result.widgets.number).toHaveProperty("factory");
|
||||
expect(result.widgets.multiselect).toHaveProperty("factory");
|
||||
expect(result.widgets.select).toHaveProperty("factory");
|
||||
expect(result.widgets.phone).toHaveProperty("factory");
|
||||
expect(result.widgets.email).toHaveProperty("factory");
|
||||
});
|
||||
|
||||
it("should add render functions to settings", () => {
|
||||
const result = withRaqbSettingsAndWidgets({
|
||||
config: mockConfig,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
// Verify settings have all required render functions
|
||||
expect(result.settings).toHaveProperty("renderField");
|
||||
expect(result.settings).toHaveProperty("renderOperator");
|
||||
expect(result.settings).toHaveProperty("renderFunc");
|
||||
expect(result.settings).toHaveProperty("renderConjs");
|
||||
expect(result.settings).toHaveProperty("renderButton");
|
||||
expect(result.settings).toHaveProperty("renderButtonGroup");
|
||||
expect(result.settings).toHaveProperty("renderProvider");
|
||||
});
|
||||
|
||||
it("should preserve existing widget properties while adding factory", () => {
|
||||
const configWithExistingProps = {
|
||||
...mockConfig,
|
||||
widgets: {
|
||||
...mockConfig.widgets,
|
||||
text: {
|
||||
type: "text",
|
||||
existingProp: "value",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = withRaqbSettingsAndWidgets({
|
||||
config: configWithExistingProps,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
expect(result.widgets.text).toHaveProperty("existingProp", "value");
|
||||
expect(result.widgets.text).toHaveProperty("factory");
|
||||
});
|
||||
|
||||
it("should set correct placeholder for phone widget", () => {
|
||||
const result = withRaqbSettingsAndWidgets({
|
||||
config: mockConfig,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
expect(result.widgets.phone).toHaveProperty("valuePlaceholder", "Enter Phone Number");
|
||||
});
|
||||
|
||||
it("should reuse same component fns for widgets - ensures remounting is not happening", () => {
|
||||
const configWithExistingProps = {
|
||||
...mockConfig,
|
||||
};
|
||||
|
||||
const result = withRaqbSettingsAndWidgets({
|
||||
config: configWithExistingProps,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
// @ts-expect-error - TODO: fix this
|
||||
const textFactory = result.widgets.text.factory;
|
||||
|
||||
const result2 = withRaqbSettingsAndWidgets({
|
||||
config: configWithExistingProps,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
// @ts-expect-error - TODO: fix this
|
||||
// Using same reference
|
||||
expect(result2.widgets.text.factory).toBe(textFactory);
|
||||
});
|
||||
|
||||
it("should reuse same component fns for settings - ensures remounting is not happening", () => {
|
||||
const configWithExistingProps = {
|
||||
...mockConfig,
|
||||
};
|
||||
|
||||
const result1 = withRaqbSettingsAndWidgets({
|
||||
config: configWithExistingProps,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
const renderFieldOnFirstCallOfWithRaqbSettingsAndWidgets = result1.settings.renderField;
|
||||
|
||||
const result2 = withRaqbSettingsAndWidgets({
|
||||
config: configWithExistingProps,
|
||||
configFor: ConfigFor.FormFields,
|
||||
});
|
||||
|
||||
expect(result2.settings.renderField).toBe(renderFieldOnFirstCallOfWithRaqbSettingsAndWidgets);
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-7
@@ -116,16 +116,21 @@ function withFactoryWidgets(widgets: WidgetsWithoutFactory) {
|
||||
return widgetsWithFactory;
|
||||
}
|
||||
|
||||
// These are components and components reference when changed causes remounting of components. So, ensure that renderField and others are defined only once
|
||||
const sharedSettingsProps: Partial<Settings> = {
|
||||
renderField: (props) => renderComponent(props, FieldSelect),
|
||||
renderOperator: (props) => renderComponent(props, FieldSelect),
|
||||
renderFunc: (props) => renderComponent(props, FieldSelect),
|
||||
renderConjs: (props) => renderComponent(props, Conjs),
|
||||
renderButton: (props) => renderComponent(props, Button),
|
||||
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
|
||||
renderProvider: (props) => renderComponent(props, Provider),
|
||||
};
|
||||
|
||||
function withRenderFnsSettings(settings: Settings) {
|
||||
const settingsWithRenderFns: Settings = {
|
||||
...settings,
|
||||
renderField: (props) => renderComponent(props, FieldSelect),
|
||||
renderOperator: (props) => renderComponent(props, FieldSelect),
|
||||
renderFunc: (props) => renderComponent(props, FieldSelect),
|
||||
renderConjs: (props) => renderComponent(props, Conjs),
|
||||
renderButton: (props) => renderComponent(props, Button),
|
||||
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
|
||||
renderProvider: (props) => renderComponent(props, Provider),
|
||||
...sharedSettingsProps,
|
||||
};
|
||||
return settingsWithRenderFns;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function getServerTimingHeader(timeTaken: Record<string, number | null | undefined>) {
|
||||
const headerParts = Object.entries(timeTaken)
|
||||
.map(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
return `${key};dur=${value}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return headerParts.join(", ");
|
||||
}
|
||||
+61
-111
@@ -5,58 +5,55 @@ import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import isRouter from "../lib/isRouter";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { TResponseInputSchema } from "./response.schema";
|
||||
import { onFormSubmission } from "./utils";
|
||||
import { onFormSubmission } from "../trpc/utils";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] });
|
||||
type Form = SerializableForm<
|
||||
App_RoutingForms_Form & {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
team: {
|
||||
parentId: number | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
|
||||
interface ResponseHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TResponseInputSchema;
|
||||
}
|
||||
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
|
||||
|
||||
export const handleResponse = async ({
|
||||
response,
|
||||
form,
|
||||
// Unused but probably should be used
|
||||
// formFillerId,
|
||||
chosenRouteId,
|
||||
}: {
|
||||
response: z.infer<typeof ZResponseInputSchema>["response"];
|
||||
form: Form;
|
||||
formFillerId: string;
|
||||
chosenRouteId: string | null;
|
||||
}) => {
|
||||
try {
|
||||
const { response, formId, chosenRouteId } = input;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm({ form });
|
||||
if (!serializableForm.fields) {
|
||||
if (!form.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const formTeamId = form.teamId;
|
||||
const formOrgId = form.team?.parentId ?? null;
|
||||
const serializableFormWithFields = {
|
||||
...serializableForm,
|
||||
fields: serializableForm.fields,
|
||||
...form,
|
||||
fields: form.fields,
|
||||
};
|
||||
|
||||
const missingFields = serializableFormWithFields.fields
|
||||
@@ -120,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
|
||||
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
|
||||
let teamMemberIdsMatchingAttributeLogic: number[] | null = null;
|
||||
|
||||
let timeTaken: Record<string, number | null> = {};
|
||||
if (chosenRoute) {
|
||||
if (isRouter(chosenRoute)) {
|
||||
throw new TRPCError({
|
||||
@@ -128,17 +125,25 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
message: "Chosen route is a router",
|
||||
});
|
||||
}
|
||||
const teamMembersMatchingAttributeLogicWithResult = form.teamId
|
||||
? await findTeamMembersMatchingAttributeLogic({
|
||||
dynamicFieldValueOperands: {
|
||||
response,
|
||||
fields: serializableForm.fields || [],
|
||||
},
|
||||
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
|
||||
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
|
||||
teamId: form.teamId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const teamMembersMatchingAttributeLogicWithResult =
|
||||
formTeamId && formOrgId
|
||||
? await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
dynamicFieldValueOperands: {
|
||||
response,
|
||||
fields: form.fields || [],
|
||||
},
|
||||
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
|
||||
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
|
||||
teamId: formTeamId,
|
||||
orgId: formOrgId,
|
||||
},
|
||||
{
|
||||
enablePerf: true,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
moduleLogger.debug(
|
||||
"teamMembersMatchingAttributeLogic",
|
||||
@@ -151,71 +156,17 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
(member) => member.userId
|
||||
)
|
||||
: null;
|
||||
|
||||
timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {};
|
||||
} else {
|
||||
// It currently happens for a Router route. Such a route id isn't present in the form.routes
|
||||
}
|
||||
|
||||
// const chosenRouteName = `Route ${chosenRouteIndex + 1}`;
|
||||
|
||||
// if (input.isPreview) {
|
||||
// // Detect if response has value for a field that isn't in the field list
|
||||
// const formFields = serializableFormWithFields.fields.map((field) => field.id);
|
||||
// const extraFields = Object.keys(response).filter((fieldId) => !formFields.includes(fieldId));
|
||||
// const attributeRoutingConfig =
|
||||
// "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null;
|
||||
|
||||
// let previewData = {
|
||||
// teamMemberIdsMatchingAttributeLogic,
|
||||
// chosenRoute: {
|
||||
// name: chosenRouteName,
|
||||
// action: "action" in chosenRoute ? chosenRoute.action : null,
|
||||
// },
|
||||
// skipContactOwner: attributeRoutingConfig?.skipContactOwner ?? false,
|
||||
// warnings: [] as string[],
|
||||
// errors: [] as string[],
|
||||
// };
|
||||
|
||||
// if (extraFields.length > 0) {
|
||||
// // If response submitted directly through the /response.handler, it is useful to know which fields were non-existent
|
||||
// // If we reach here through router, all extra fields are already removed from here
|
||||
// previewData.warnings.push(
|
||||
// `Response contains values for non-existent fields: ${extraFields.join(", ")}`
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Check for values not present in options for SINGLE_SELECT and MULTISELECT fields
|
||||
// serializableFormWithFields.fields.forEach((field) => {
|
||||
// if (
|
||||
// field.type !== RoutingFormFieldType.SINGLE_SELECT &&
|
||||
// field.type !== RoutingFormFieldType.MULTI_SELECT
|
||||
// ) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const fieldResponse = response[field.id];
|
||||
|
||||
// if (fieldResponse && fieldResponse.value) {
|
||||
// const values = Array.isArray(fieldResponse.value) ? fieldResponse.value : [fieldResponse.value];
|
||||
// const invalidValues = values.filter(
|
||||
// (value) => !field.options?.some((option) => option.id === value || option.label === value)
|
||||
// );
|
||||
// if (invalidValues.length > 0) {
|
||||
// previewData.errors.push(`Invalid value(s) for ${field.label}: ${invalidValues.join(", ")}`);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// return {
|
||||
// isPreview: true,
|
||||
// previewData,
|
||||
// formResponse: null,
|
||||
// teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
|
||||
// };
|
||||
// }
|
||||
|
||||
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId,
|
||||
// TODO: Why do we not save formFillerId available in the input?
|
||||
// formFillerId,
|
||||
formId: form.id,
|
||||
response: response,
|
||||
chosenRouteId,
|
||||
},
|
||||
@@ -237,6 +188,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
? chosenRoute.attributeRoutingConfig
|
||||
: null
|
||||
: null,
|
||||
timeTaken,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -249,5 +201,3 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export default responseHandler;
|
||||
@@ -9,8 +9,6 @@ import { getServerSideProps as getServerSidePropsForms } from "./forms/getServer
|
||||
import * as LayoutHandler from "./layout-handler/[...appPages]";
|
||||
import * as Reporting from "./reporting/[...appPages]";
|
||||
import * as RouteBuilder from "./route-builder/[...appPages]";
|
||||
import * as Router from "./router/[...appPages]";
|
||||
import { getServerSideProps as getServerSidePropsRouter } from "./router/getServerSideProps";
|
||||
import * as RoutingLink from "./routing-link/[...appPages]";
|
||||
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
|
||||
|
||||
@@ -19,7 +17,6 @@ const routingConfig = {
|
||||
"route-builder": RouteBuilder,
|
||||
forms: forms,
|
||||
"routing-link": RoutingLink,
|
||||
router: Router,
|
||||
reporting: Reporting,
|
||||
layoutHandler: LayoutHandler,
|
||||
};
|
||||
@@ -29,7 +26,6 @@ export const serverSidePropsConfig: Record<string, AppGetServerSideProps> = {
|
||||
"form-edit": getServerSidePropsSingleForm,
|
||||
"route-builder": getServerSidePropsSingleForm,
|
||||
"routing-link": getServerSidePropsRoutingLink,
|
||||
router: getServerSidePropsRouter,
|
||||
reporting: getServerSidePropsSingleForm,
|
||||
};
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
sdkActionManager?.fire("__routeChanged", {});
|
||||
}, [customPageMessage]);
|
||||
|
||||
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
|
||||
const responseMutation = trpc.viewer.routingForms.public.response.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const { teamMembersMatchingAttributeLogic, formResponse, attributeRoutingConfig } = data;
|
||||
const chosenRouteWithFormResponse = chosenRouteWithFormResponseRef.current;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppG
|
||||
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
|
||||
export async function isAuthorizedToViewTheForm({
|
||||
export function isAuthorizedToViewTheForm({
|
||||
user,
|
||||
currentOrgDomain,
|
||||
}: {
|
||||
@@ -108,7 +108,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
|
||||
};
|
||||
|
||||
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
|
||||
if (!isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain })) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
|
||||
@@ -10,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
|
||||
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
|
||||
import { forms } from "./procedures/forms";
|
||||
import { ZReportInputSchema } from "./report.schema";
|
||||
import { ZResponseInputSchema } from "./response.schema";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
|
||||
@@ -53,12 +51,6 @@ export const ZFormByResponseIdInputSchema = z.object({
|
||||
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
|
||||
|
||||
const appRoutingForms = router({
|
||||
public: router({
|
||||
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("response", () => import("./response.handler"));
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
}),
|
||||
forms,
|
||||
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
|
||||
|
||||
@@ -157,6 +157,45 @@ const _getUser = async (where: Prisma.UserWhereInput) => {
|
||||
|
||||
type GetUser = Awaited<ReturnType<typeof getUser>>;
|
||||
|
||||
export type GetUserAvailabilityInitialData = {
|
||||
user?: GetUser;
|
||||
eventType?: EventType;
|
||||
currentSeats?: CurrentSeats;
|
||||
rescheduleUid?: string | null;
|
||||
currentBookings?: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
|
||||
eventType: Pick<
|
||||
PrismaEventType,
|
||||
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
|
||||
> | null;
|
||||
_count?: {
|
||||
seatsReferences: number;
|
||||
};
|
||||
})[];
|
||||
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
|
||||
user: Pick<User, "id" | "name">;
|
||||
toUser: Pick<User, "id" | "username" | "name"> | null;
|
||||
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
|
||||
})[];
|
||||
busyTimesFromLimitsBookings: EventBusyDetails[];
|
||||
};
|
||||
|
||||
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
|
||||
|
||||
type GetUserAvailabilityQuery = {
|
||||
withSource?: boolean;
|
||||
username?: string;
|
||||
userId?: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
afterEventBuffer?: number;
|
||||
beforeEventBuffer?: number;
|
||||
duration?: number;
|
||||
returnDateOverrides: boolean;
|
||||
bypassBusyCalendarTimes: boolean;
|
||||
shouldServeCache?: boolean;
|
||||
};
|
||||
|
||||
export const getCurrentSeats = async (
|
||||
...args: Parameters<typeof _getCurrentSeats>
|
||||
): Promise<ReturnType<typeof _getCurrentSeats>> => {
|
||||
@@ -222,47 +261,16 @@ export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
|
||||
|
||||
export const getUserAvailability = async (
|
||||
...args: Parameters<typeof _getUserAvailability>
|
||||
): Promise<ReturnType<typeof _getUserAvailability>> => {
|
||||
): Promise<GetUserAvailabilityResult> => {
|
||||
return monitorCallbackAsync(_getUserAvailability, ...args);
|
||||
};
|
||||
|
||||
type GetUserAvailabilityResult = ReturnType<typeof _getUserAvailability>;
|
||||
|
||||
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
|
||||
const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse(
|
||||
query: {
|
||||
withSource?: boolean;
|
||||
username?: string;
|
||||
userId?: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
afterEventBuffer?: number;
|
||||
beforeEventBuffer?: number;
|
||||
duration?: number;
|
||||
returnDateOverrides: boolean;
|
||||
bypassBusyCalendarTimes: boolean;
|
||||
shouldServeCache?: boolean;
|
||||
},
|
||||
initialData?: {
|
||||
user?: GetUser;
|
||||
eventType?: EventType;
|
||||
currentSeats?: CurrentSeats;
|
||||
rescheduleUid?: string | null;
|
||||
currentBookings?: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
|
||||
eventType: Pick<
|
||||
PrismaEventType,
|
||||
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
|
||||
> | null;
|
||||
_count?: {
|
||||
seatsReferences: number;
|
||||
};
|
||||
})[];
|
||||
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
|
||||
user: Pick<User, "id" | "name">;
|
||||
toUser: Pick<User, "id" | "username" | "name"> | null;
|
||||
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
|
||||
})[];
|
||||
busyTimesFromLimitsBookings: EventBusyDetails[];
|
||||
}
|
||||
query: GetUserAvailabilityQuery,
|
||||
initialData?: GetUserAvailabilityInitialData
|
||||
) {
|
||||
const {
|
||||
username,
|
||||
@@ -656,22 +664,16 @@ const calculateOutOfOfficeRanges = (
|
||||
}, {});
|
||||
};
|
||||
|
||||
type GetUserAvailabilityQuery = Parameters<typeof getUserAvailability>[0];
|
||||
type GetUserAvailabilityInitialData = NonNullable<Parameters<typeof getUserAvailability>[1]>;
|
||||
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
|
||||
|
||||
const _getUsersAvailability = async ({
|
||||
users,
|
||||
query,
|
||||
initialData,
|
||||
}: {
|
||||
type GetUsersAvailabilityProps = {
|
||||
users: (GetAvailabilityUser & {
|
||||
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
|
||||
outOfOfficeDays?: GetUserAvailabilityInitialData["outOfOfficeDays"];
|
||||
})[];
|
||||
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
|
||||
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
|
||||
}) => {
|
||||
};
|
||||
|
||||
const _getUsersAvailability = async ({ users, query, initialData }: GetUsersAvailabilityProps) => {
|
||||
return await Promise.all(
|
||||
users.map((user) =>
|
||||
_getUserAvailability(
|
||||
@@ -695,6 +697,6 @@ const _getUsersAvailability = async ({
|
||||
|
||||
export const getUsersAvailability = async (
|
||||
...args: Parameters<typeof _getUsersAvailability>
|
||||
): Promise<ReturnType<typeof _getUsersAvailability>> => {
|
||||
): Promise<Awaited<GetUserAvailabilityResult>[]> => {
|
||||
return monitorCallbackAsync(_getUsersAvailability, ...args);
|
||||
};
|
||||
|
||||
@@ -181,7 +181,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
|
||||
mutationFn: createBooking,
|
||||
onSuccess: (booking) => {
|
||||
if (booking.isDryRun) {
|
||||
showToast(t("booking_dry_run_successful"), "success");
|
||||
router.push("/booking/dry-run-successful");
|
||||
return;
|
||||
}
|
||||
const { uid, paymentUid } = booking;
|
||||
@@ -311,7 +311,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
|
||||
const booking = bookings[0] || {};
|
||||
|
||||
if (booking.isDryRun) {
|
||||
showToast(t("booking_dry_run_successful"), "success");
|
||||
router.push("/booking/dry-run-successful");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ export async function handleConfirmation(args: {
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
const results = scheduleResult.results;
|
||||
const metadata: AdditionalInformation = {};
|
||||
|
||||
const workflows = await getAllWorkflowsFromEventType(eventType, booking.userId);
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
@@ -339,14 +338,14 @@ export async function handleConfirmation(args: {
|
||||
const isFirstBooking = index === 0;
|
||||
|
||||
if (!eventTypeMetadata?.disableStandardEmails?.all?.attendee) {
|
||||
await scheduleMandatoryReminder(
|
||||
evtOfBooking,
|
||||
await scheduleMandatoryReminder({
|
||||
evt: evtOfBooking,
|
||||
workflows,
|
||||
false,
|
||||
!!updatedBookings[index].eventType?.owner?.hideBranding,
|
||||
evt.attendeeSeatId,
|
||||
!emailsEnabled && Boolean(platformClientParams?.platformClientId)
|
||||
);
|
||||
requiresConfirmation: false,
|
||||
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isPlatformNoEmail: !emailsEnabled && Boolean(platformClientParams?.platformClientId),
|
||||
});
|
||||
}
|
||||
|
||||
await scheduleWorkflowReminders({
|
||||
|
||||
@@ -1109,6 +1109,7 @@ async function handler(
|
||||
responses,
|
||||
workflows,
|
||||
rescheduledBy: reqBody.rescheduledBy,
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
if (newBooking) {
|
||||
@@ -1778,15 +1779,16 @@ async function handler(
|
||||
|
||||
// Convert type of eventTypePaymentAppCredential to appId: EventTypeAppList
|
||||
if (!booking.user) booking.user = organizerUser;
|
||||
const payment = await handlePayment(
|
||||
const payment = await handlePayment({
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
selectedEventType: eventType,
|
||||
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking,
|
||||
fullName,
|
||||
bookerName: fullName,
|
||||
bookerEmail,
|
||||
bookerPhoneNumber
|
||||
);
|
||||
bookerPhoneNumber,
|
||||
isDryRun,
|
||||
});
|
||||
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
|
||||
userId: triggerForUser ? organizerUser.id : null,
|
||||
eventTypeId,
|
||||
@@ -1802,6 +1804,7 @@ async function handler(
|
||||
...webhookData,
|
||||
paymentId: payment?.id,
|
||||
},
|
||||
isDryRun,
|
||||
});
|
||||
|
||||
req.statusCode = 201;
|
||||
@@ -1844,6 +1847,7 @@ async function handler(
|
||||
//delete all scheduled triggers for meeting ended and meeting started of booking
|
||||
deleteWebhookScheduledTriggerPromise = deleteWebhookScheduledTriggers({
|
||||
booking: originalRescheduledBooking,
|
||||
isDryRun,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1855,6 +1859,7 @@ async function handler(
|
||||
subscriberUrl: subscriber.subscriberUrl,
|
||||
subscriber,
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||
isDryRun,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1866,6 +1871,7 @@ async function handler(
|
||||
subscriberUrl: subscriber.subscriberUrl,
|
||||
subscriber,
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
|
||||
isDryRun,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1879,13 +1885,23 @@ async function handler(
|
||||
});
|
||||
|
||||
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
|
||||
await monitorCallbackAsync(handleWebhookTrigger, { subscriberOptions, eventTrigger, webhookData });
|
||||
await monitorCallbackAsync(handleWebhookTrigger, {
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
webhookData,
|
||||
isDryRun,
|
||||
});
|
||||
} else {
|
||||
// if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook
|
||||
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED;
|
||||
subscriberOptions.triggerEvent = eventTrigger;
|
||||
webhookData.status = "PENDING";
|
||||
await monitorCallbackAsync(handleWebhookTrigger, { subscriberOptions, eventTrigger, webhookData });
|
||||
await monitorCallbackAsync(handleWebhookTrigger, {
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
webhookData,
|
||||
isDryRun,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1931,14 +1947,15 @@ async function handler(
|
||||
};
|
||||
|
||||
if (!eventType.metadata?.disableStandardEmails?.all?.attendee) {
|
||||
await scheduleMandatoryReminder(
|
||||
evtWithMetadata,
|
||||
await scheduleMandatoryReminder({
|
||||
evt: evtWithMetadata,
|
||||
workflows,
|
||||
!isConfirmedByDefault,
|
||||
!!eventType.owner?.hideBranding,
|
||||
evt.attendeeSeatId,
|
||||
noEmail && Boolean(platformClientId)
|
||||
);
|
||||
requiresConfirmation: !isConfirmedByDefault,
|
||||
hideBranding: !!eventType.owner?.hideBranding,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isPlatformNoEmail: noEmail && Boolean(platformClientId),
|
||||
isDryRun,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1951,6 +1968,7 @@ async function handler(
|
||||
isFirstRecurringEvent: req.body.allRecurringDates ? req.body.isFirstRecurringSlot : undefined,
|
||||
hideBranding: !!eventType.owner?.hideBranding,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isDryRun,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
@@ -1965,6 +1983,7 @@ async function handler(
|
||||
eventTypeId,
|
||||
teamId,
|
||||
orgId,
|
||||
isDryRun,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,10 +14,22 @@ type ScheduleNoShowTriggersArgs = {
|
||||
teamId?: number | null;
|
||||
orgId?: number | null;
|
||||
oAuthClientId?: string | null;
|
||||
isDryRun?: boolean;
|
||||
};
|
||||
|
||||
export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => {
|
||||
const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId, oAuthClientId } = args;
|
||||
const {
|
||||
booking,
|
||||
triggerForUser,
|
||||
organizerUser,
|
||||
eventTypeId,
|
||||
teamId,
|
||||
orgId,
|
||||
oAuthClientId,
|
||||
isDryRun = false,
|
||||
} = args;
|
||||
|
||||
if (isDryRun) return;
|
||||
|
||||
// Add task for automatic no show in cal video
|
||||
const noShowPromises: Promise<any>[] = [];
|
||||
|
||||
@@ -187,15 +187,15 @@ const createNewSeat = async (
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentAppId });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
const payment = await handlePayment({
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
seatedBooking,
|
||||
fullName,
|
||||
selectedEventType: eventType,
|
||||
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking: seatedBooking,
|
||||
bookerName: fullName,
|
||||
bookerEmail,
|
||||
bookerPhoneNumber
|
||||
);
|
||||
bookerPhoneNumber,
|
||||
});
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
|
||||
@@ -32,8 +32,10 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
evt,
|
||||
workflows,
|
||||
rescheduledBy,
|
||||
isDryRun = false,
|
||||
} = newSeatedBookingObject;
|
||||
|
||||
// TODO: We could allow doing more things to support good dry run for seats
|
||||
if (isDryRun) return;
|
||||
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
|
||||
|
||||
let resultBooking: HandleSeatsResultBooking = null;
|
||||
@@ -124,6 +126,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
isDryRun,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
@@ -149,7 +152,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
rescheduledBy,
|
||||
};
|
||||
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData, isDryRun });
|
||||
}
|
||||
|
||||
return resultBooking;
|
||||
|
||||
@@ -43,6 +43,7 @@ export type NewSeatedBookingObject = {
|
||||
responses: z.infer<ReturnType<typeof getBookingDataSchema>>["responses"] | null;
|
||||
rescheduledBy?: string;
|
||||
workflows: Workflow[];
|
||||
isDryRun?: boolean;
|
||||
};
|
||||
|
||||
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
|
||||
|
||||
@@ -9,8 +9,10 @@ export async function handleWebhookTrigger(args: {
|
||||
subscriberOptions: GetSubscriberOptions;
|
||||
eventTrigger: string;
|
||||
webhookData: WebhookPayloadType;
|
||||
isDryRun?: boolean;
|
||||
}) {
|
||||
try {
|
||||
if (args.isDryRun) return;
|
||||
const subscribers = await getWebhooks(args.subscriberOptions);
|
||||
|
||||
const promises = subscribers.map((sub) =>
|
||||
|
||||
@@ -113,7 +113,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="col-span-2 mb-6">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
{date.locale(i18n.language).format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||
<span className="text-subtle">({timezone})</span>
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams
|
||||
isNotConfirmed?: boolean;
|
||||
isRescheduleEvent?: boolean;
|
||||
isFirstRecurringEvent?: boolean;
|
||||
isDryRun?: boolean;
|
||||
}
|
||||
|
||||
const processWorkflowStep = async (
|
||||
@@ -159,7 +160,9 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
|
||||
emailAttendeeSendToOverride = "",
|
||||
hideBranding,
|
||||
seatReferenceUid,
|
||||
isDryRun = false,
|
||||
} = args;
|
||||
if (isDryRun) return;
|
||||
if (isNotConfirmed || !workflows.length) return;
|
||||
|
||||
for (const workflow of workflows) {
|
||||
|
||||
@@ -11,14 +11,24 @@ const log = logger.getSubLogger({ prefix: ["[scheduleMandatoryReminder]"] });
|
||||
|
||||
export type NewBookingEventType = Awaited<ReturnType<typeof getDefaultEvent>> | getEventTypeResponse;
|
||||
|
||||
export async function scheduleMandatoryReminder(
|
||||
evt: ExtendedCalendarEvent,
|
||||
workflows: Workflow[],
|
||||
requiresConfirmation: boolean,
|
||||
hideBranding: boolean,
|
||||
seatReferenceUid: string | undefined,
|
||||
isPlatformNoEmail = false
|
||||
) {
|
||||
export async function scheduleMandatoryReminder({
|
||||
evt,
|
||||
workflows,
|
||||
requiresConfirmation,
|
||||
hideBranding,
|
||||
seatReferenceUid,
|
||||
isPlatformNoEmail = false,
|
||||
isDryRun = false,
|
||||
}: {
|
||||
evt: ExtendedCalendarEvent;
|
||||
workflows: Workflow[];
|
||||
requiresConfirmation: boolean;
|
||||
hideBranding: boolean;
|
||||
seatReferenceUid: string | undefined;
|
||||
isPlatformNoEmail?: boolean;
|
||||
isDryRun?: boolean;
|
||||
}) {
|
||||
if (isDryRun) return;
|
||||
if (isPlatformNoEmail) return;
|
||||
try {
|
||||
const hasExistingWorkflow = workflows.some((workflow) => {
|
||||
|
||||
@@ -246,12 +246,15 @@ export async function scheduleTrigger({
|
||||
subscriberUrl,
|
||||
subscriber,
|
||||
triggerEvent,
|
||||
isDryRun = false,
|
||||
}: {
|
||||
booking: { id: number; endTime: Date; startTime: Date };
|
||||
subscriberUrl: string;
|
||||
subscriber: { id: string; appId: string | null };
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
isDryRun?: boolean;
|
||||
}) {
|
||||
if (isDryRun) return;
|
||||
try {
|
||||
const payload = JSON.stringify({ triggerEvent, ...booking });
|
||||
|
||||
@@ -285,6 +288,7 @@ export async function deleteWebhookScheduledTriggers({
|
||||
webhookId,
|
||||
userId,
|
||||
teamId,
|
||||
isDryRun = false,
|
||||
}: {
|
||||
booking?: { id: number; uid: string };
|
||||
appId?: string | null;
|
||||
@@ -292,7 +296,9 @@ export async function deleteWebhookScheduledTriggers({
|
||||
webhookId?: string;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
isDryRun?: boolean;
|
||||
}) {
|
||||
if (isDryRun) return;
|
||||
try {
|
||||
if (appId && (userId || teamId)) {
|
||||
const where: Prisma.BookingWhereInput = {};
|
||||
|
||||
@@ -114,10 +114,8 @@ describe("filterHostByLeadThreshold", () => {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
id: {
|
||||
in: [2],
|
||||
},
|
||||
userId: {
|
||||
in: [2],
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
|
||||
@@ -70,13 +70,14 @@ async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!eventType.team) {
|
||||
if (!eventType.team || !eventType.team.parentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic({
|
||||
attributesQueryValue: eventType.rrSegmentQueryValue ?? null,
|
||||
teamId: eventType.team.id,
|
||||
orgId: eventType.team.parentId,
|
||||
});
|
||||
if (!teamMembersMatchingAttributeLogic) {
|
||||
return teamMembersMatchingAttributeLogic;
|
||||
@@ -102,7 +103,7 @@ type EventType = {
|
||||
assignAllTeamMembers: boolean;
|
||||
assignRRMembersUsingSegment: boolean;
|
||||
rrSegmentQueryValue: AttributesQueryValue | null | undefined;
|
||||
team: { id: number } | null;
|
||||
team: { id: number; parentId: number | null } | null;
|
||||
};
|
||||
|
||||
export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<User>>({
|
||||
|
||||
@@ -7,9 +7,18 @@ import { eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService";
|
||||
|
||||
const handlePayment = async (
|
||||
evt: CalendarEvent,
|
||||
selectedEventType: Pick<CompleteEventType, "metadata" | "title">,
|
||||
const handlePayment = async ({
|
||||
evt,
|
||||
selectedEventType,
|
||||
paymentAppCredentials,
|
||||
booking,
|
||||
bookerName,
|
||||
bookerEmail,
|
||||
bookerPhoneNumber,
|
||||
isDryRun = false,
|
||||
}: {
|
||||
evt: CalendarEvent;
|
||||
selectedEventType: Pick<CompleteEventType, "metadata" | "title">;
|
||||
paymentAppCredentials: {
|
||||
key: Prisma.JsonValue;
|
||||
appId: EventTypeAppsList;
|
||||
@@ -17,18 +26,20 @@ const handlePayment = async (
|
||||
dirName: string;
|
||||
categories: AppCategories[];
|
||||
} | null;
|
||||
},
|
||||
};
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string; username: string | null } | null;
|
||||
id: number;
|
||||
userId: number | null;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
bookerName: string,
|
||||
bookerEmail: string,
|
||||
bookerPhoneNumber?: string | null
|
||||
) => {
|
||||
};
|
||||
bookerName: string;
|
||||
bookerEmail: string;
|
||||
bookerPhoneNumber?: string | null;
|
||||
isDryRun?: boolean;
|
||||
}) => {
|
||||
if (isDryRun) return null;
|
||||
const paymentApp = (await appStore[
|
||||
paymentAppCredentials?.app?.dirName as keyof typeof appStore
|
||||
]?.()) as PaymentApp;
|
||||
|
||||
@@ -18,41 +18,63 @@ vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
|
||||
default: {},
|
||||
}));
|
||||
vi.mock("@calcom/ui", () => ({}));
|
||||
|
||||
const orgId = 1001;
|
||||
function mockAttributesScenario({
|
||||
attributes,
|
||||
teamMembersWithAttributeOptionValuePerAttribute,
|
||||
}: {
|
||||
attributes: Awaited<ReturnType<typeof getAttributesModule.getAttributesForTeam>>;
|
||||
attributes: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AttributeType;
|
||||
slug: string;
|
||||
options: {
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
}[];
|
||||
}[];
|
||||
teamMembersWithAttributeOptionValuePerAttribute: {
|
||||
userId: number;
|
||||
attributes: Record<string, string | string[]>;
|
||||
}[];
|
||||
}) {
|
||||
vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes);
|
||||
vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue(
|
||||
teamMembersWithAttributeOptionValuePerAttribute.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
attributes: Object.fromEntries(
|
||||
Object.entries(member.attributes).map(([attributeId, value]) => {
|
||||
return [
|
||||
attributeId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
{
|
||||
attributeOption:
|
||||
value instanceof Array
|
||||
? value.map((value) => ({ value, isGroup: false, contains: [] }))
|
||||
: { value, isGroup: false, contains: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
type: attributes.find((attribute) => attribute.id === attributeId)!.type,
|
||||
},
|
||||
];
|
||||
})
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
const commonOptionsProps = {
|
||||
isGroup: false,
|
||||
contains: [],
|
||||
};
|
||||
vi.mocked(getAttributesModule.getAttributesAssignmentData).mockResolvedValue({
|
||||
attributesOfTheOrg: attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
options: attribute.options.map((option) => ({
|
||||
...option,
|
||||
...commonOptionsProps,
|
||||
})),
|
||||
})),
|
||||
attributesAssignedToTeamMembersWithOptions: teamMembersWithAttributeOptionValuePerAttribute.map(
|
||||
(member) => {
|
||||
return {
|
||||
...member,
|
||||
attributes: Object.fromEntries(
|
||||
Object.entries(member.attributes).map(([attributeId, value]) => {
|
||||
return [
|
||||
attributeId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
{
|
||||
attributeOption:
|
||||
value instanceof Array
|
||||
? value.map((value) => ({ value, isGroup: false, contains: [] }))
|
||||
: { value, isGroup: false, contains: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
type: attributes.find((attribute) => attribute.id === attributeId)!.type,
|
||||
},
|
||||
];
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function mockHugeAttributesOfTypeSingleSelect({
|
||||
@@ -253,6 +275,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue: { type: "group" } as unknown as AttributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
},
|
||||
{
|
||||
enableTroubleshooter: true,
|
||||
@@ -302,6 +325,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -374,6 +398,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue: attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -438,6 +463,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -503,6 +529,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -571,6 +598,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -596,6 +624,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: null,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(null);
|
||||
@@ -619,6 +648,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: matchingAttributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(checkedFallback).toEqual(true);
|
||||
@@ -647,6 +677,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: failingAttributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
});
|
||||
|
||||
expect(checkedFallback).toEqual(true);
|
||||
@@ -691,6 +722,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
],
|
||||
}),
|
||||
teamId: 1,
|
||||
orgId,
|
||||
})
|
||||
).rejects.toThrow("Unsupported attribute type");
|
||||
});
|
||||
@@ -750,6 +782,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
isPreview: mode === "preview" ? true : false,
|
||||
fallbackAttributesQueryValue: null,
|
||||
});
|
||||
@@ -825,6 +858,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
isPreview: mode === "preview" ? true : false,
|
||||
});
|
||||
return result;
|
||||
@@ -871,6 +905,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
},
|
||||
{
|
||||
concurrency: 1,
|
||||
@@ -975,6 +1010,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
},
|
||||
{
|
||||
enableTroubleshooter: true,
|
||||
|
||||
@@ -4,10 +4,7 @@ import type { Config } from "react-awesome-query-builder/lib";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
|
||||
|
||||
import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
|
||||
import {
|
||||
getTeamMembersWithAttributeOptionValuePerAttribute,
|
||||
getAttributesForTeam,
|
||||
} from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import { getAttributesAssignmentData } from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes";
|
||||
|
||||
import { RaqbLogicResult } from "./evaluateRaqbLogic";
|
||||
@@ -22,13 +19,13 @@ const {
|
||||
} = acrossQueryValueCompatiblity;
|
||||
|
||||
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
|
||||
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
|
||||
>[number];
|
||||
ReturnType<typeof getAttributesAssignmentData>
|
||||
>["attributesAssignedToTeamMembersWithOptions"][number];
|
||||
|
||||
type RunAttributeLogicData = {
|
||||
attributesQueryValue: AttributesQueryValue | null;
|
||||
attributesData: {
|
||||
attributesForTeam: Attribute[];
|
||||
attributesOfTheOrg: Attribute[];
|
||||
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
|
||||
};
|
||||
dynamicFieldValueOperands?: dynamicFieldValueOperands;
|
||||
@@ -183,13 +180,13 @@ async function getLogicResultForAllMembers(
|
||||
async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
|
||||
const {
|
||||
attributesQueryValue: _attributesQueryValue,
|
||||
attributesData: { attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute },
|
||||
attributesData: { attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute },
|
||||
dynamicFieldValueOperands,
|
||||
} = data;
|
||||
const { concurrency, enablePerf, enableTroubleshooter } = options;
|
||||
const attributesQueryValue = getAttributesQueryValue({
|
||||
attributesQueryValue: _attributesQueryValue ?? null,
|
||||
attributes: attributesForTeam,
|
||||
attributes: attributesOfTheOrg,
|
||||
dynamicFieldValueOperands,
|
||||
});
|
||||
|
||||
@@ -209,7 +206,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
|
||||
const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() =>
|
||||
getAttributesQueryBuilderConfigHavingListofLabels({
|
||||
dynamicFieldValueOperands,
|
||||
attributes: attributesForTeam,
|
||||
attributes: attributesOfTheOrg,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -272,7 +269,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
|
||||
attributesQueryValue,
|
||||
attributesQueryBuilderConfig,
|
||||
logic,
|
||||
attributesForTeam,
|
||||
attributesOfTheOrg,
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -307,18 +304,14 @@ async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: R
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttributesForLogic({ teamId }: { teamId: number }) {
|
||||
const [[attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute], ttAttributes] =
|
||||
await asyncPerf(async () => {
|
||||
return [
|
||||
await getAttributesForTeam({ teamId: teamId }),
|
||||
await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }),
|
||||
];
|
||||
});
|
||||
export async function getAttributesForLogic({ teamId, orgId }: { teamId: number; orgId: number }) {
|
||||
const [result, ttAttributes] = await asyncPerf(async () => {
|
||||
return getAttributesAssignmentData({ teamId, orgId });
|
||||
});
|
||||
|
||||
return {
|
||||
attributesForTeam,
|
||||
teamMembersWithAttributeOptionValuePerAttribute,
|
||||
attributesOfTheOrg: result.attributesOfTheOrg,
|
||||
teamMembersWithAttributeOptionValuePerAttribute: result.attributesAssignedToTeamMembersWithOptions,
|
||||
timeTaken: ttAttributes,
|
||||
};
|
||||
}
|
||||
@@ -326,6 +319,7 @@ export async function getAttributesForLogic({ teamId }: { teamId: number }) {
|
||||
export async function findTeamMembersMatchingAttributeLogic(
|
||||
data: {
|
||||
teamId: number;
|
||||
orgId: number;
|
||||
attributesQueryValue: AttributesQueryValue | null;
|
||||
fallbackAttributesQueryValue?: AttributesQueryValue | null;
|
||||
dynamicFieldValueOperands?: dynamicFieldValueOperands;
|
||||
@@ -343,15 +337,22 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
// Any explicit value being passed should cause fallback to be considered. Even undefined
|
||||
const considerFallback = "fallbackAttributesQueryValue" in data;
|
||||
|
||||
const { teamId, attributesQueryValue, fallbackAttributesQueryValue, dynamicFieldValueOperands, isPreview } =
|
||||
data;
|
||||
const {
|
||||
teamId,
|
||||
orgId,
|
||||
attributesQueryValue,
|
||||
fallbackAttributesQueryValue,
|
||||
dynamicFieldValueOperands,
|
||||
isPreview,
|
||||
} = data;
|
||||
|
||||
const {
|
||||
attributesForTeam,
|
||||
attributesOfTheOrg,
|
||||
teamMembersWithAttributeOptionValuePerAttribute,
|
||||
timeTaken: ttGetAttributesForLogic,
|
||||
} = await getAttributesForLogic({
|
||||
teamId,
|
||||
orgId,
|
||||
});
|
||||
|
||||
const runAttributeLogicOptions = {
|
||||
@@ -364,7 +365,7 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
const runAttributeLogicData: Omit<RunAttributeLogicData, "attributesQueryValue"> = {
|
||||
// Change it as per the main/fallback query
|
||||
attributesData: {
|
||||
attributesForTeam,
|
||||
attributesOfTheOrg,
|
||||
teamMembersWithAttributeOptionValuePerAttribute,
|
||||
},
|
||||
dynamicFieldValueOperands,
|
||||
@@ -452,7 +453,7 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
type: TroubleshooterCase.MATCH_RESULTS_READY,
|
||||
data: {
|
||||
...troubleshooter.data,
|
||||
attributesForTeam,
|
||||
attributesOfTheOrg,
|
||||
},
|
||||
})
|
||||
: null),
|
||||
|
||||
@@ -24,4 +24,22 @@ export class AttributeRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findManyByOrgId({ orgId }: { orgId: number }) {
|
||||
// It should be a faster query because of lesser number of attributes record and index on teamId
|
||||
const result = await prisma.attribute.findMany({
|
||||
where: {
|
||||
teamId: orgId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
options: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,20 @@ export class AttributeToUserRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findManyByOrgMembershipIds({ orgMembershipIds }: { orgMembershipIds: number[] }) {
|
||||
if (!orgMembershipIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attributesAssignedToTeamMembers = await prisma.attributeToUser.findMany({
|
||||
where: {
|
||||
memberId: {
|
||||
in: orgMembershipIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return attributesAssignedToTeamMembers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,8 @@ const buildWhereClauseForActiveBookings = ({
|
||||
}): Prisma.BookingWhereInput => ({
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
id: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
userId: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
|
||||
@@ -797,6 +797,7 @@ export class EventTypeRepository {
|
||||
id: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
|
||||
@@ -258,4 +258,33 @@ export class MembershipRepository {
|
||||
|
||||
return membershipsWithSelectedCalendars;
|
||||
}
|
||||
|
||||
static async findMembershipsForBothOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: [orgId, teamId],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type Membership = (typeof memberships)[number];
|
||||
|
||||
const { teamMemberships, orgMemberships } = memberships.reduce(
|
||||
(acc, membership) => {
|
||||
if (membership.teamId === teamId) {
|
||||
acc.teamMemberships.push(membership);
|
||||
} else if (membership.teamId === orgId) {
|
||||
acc.orgMemberships.push(membership);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ teamMemberships: [] as Membership[], orgMemberships: [] as Membership[] }
|
||||
);
|
||||
|
||||
return {
|
||||
teamMemberships,
|
||||
orgMemberships,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class RoutingFormRepository {
|
||||
static async findFormByIdIncludeUserTeamAndOrg(formId: string) {
|
||||
return await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
movedToProfileId: true,
|
||||
metadata: true,
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ import { describe, expect, it, beforeEach } from "vitest";
|
||||
import type { AttributeOption } from "@calcom/prisma/client";
|
||||
import { AttributeType, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
getAttributesForTeam,
|
||||
getTeamMembersWithAttributeOptionValuePerAttribute,
|
||||
getUsersAttributes,
|
||||
} from "./getAttributes";
|
||||
import type { Attribute } from "./getAttributes";
|
||||
import { getAttributesForTeam, getAttributesAssignmentData, getUsersAttributes } from "./getAttributes";
|
||||
|
||||
// Helper functions to create test data
|
||||
async function createMockAttribute({
|
||||
@@ -43,7 +40,15 @@ async function createMockAttribute({
|
||||
})),
|
||||
});
|
||||
|
||||
return attribute;
|
||||
const result = await prismock.attribute.findUnique({
|
||||
where: { id: attribute.id },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { options: true },
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Some error in creating mock attribute");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
@@ -96,20 +101,36 @@ async function createMockTeam({ orgId }: { orgId: number | null }) {
|
||||
}
|
||||
|
||||
async function createMockAttributeAssignment({
|
||||
membershipId,
|
||||
orgMembershipId,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
membershipId: number;
|
||||
orgMembershipId: number;
|
||||
attributeOptionId: string;
|
||||
}) {
|
||||
return await prismock.attributeToUser.create({
|
||||
data: {
|
||||
memberId: membershipId,
|
||||
memberId: orgMembershipId,
|
||||
attributeOptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAttributeToMatch(attribute: Attribute, expected: Attribute) {
|
||||
expect(attribute).toMatchObject({
|
||||
id: expected.id,
|
||||
name: expected.name,
|
||||
slug: expected.slug,
|
||||
type: expected.type,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAttributesToMatch(attributes: Attribute[], expected: Attribute[]) {
|
||||
expect(attributes).toHaveLength(expected.length);
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
expectAttributeToMatch(attributes[i], expected[i]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("getAttributes", () => {
|
||||
const orgId = 1001;
|
||||
|
||||
@@ -157,7 +178,7 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: teamMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
|
||||
@@ -172,15 +193,15 @@ describe("getAttributes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamMembersWithAttributeOptionValuePerAttribute", () => {
|
||||
describe("getAttributesAssignmentData", () => {
|
||||
it("should return team members with their assigned attributes", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
const attr1 = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
@@ -199,7 +220,7 @@ describe("getAttributes", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
const attr2 = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr2",
|
||||
name: "Territory",
|
||||
@@ -212,7 +233,7 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
// Unassigned attribute
|
||||
await createMockAttribute({
|
||||
const attr3 = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr3",
|
||||
name: "Attr3",
|
||||
@@ -225,25 +246,26 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
attributeOptionId: "india-id",
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
attributeOptionId: "engineering-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
|
||||
await getAttributesAssignmentData({ teamId: team.id, orgId });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0]).toEqual({
|
||||
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
|
||||
expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({
|
||||
userId: user.id,
|
||||
attributes: {
|
||||
attr1: {
|
||||
@@ -275,16 +297,18 @@ describe("getAttributes", () => {
|
||||
// attr3 - unassigned isn't here
|
||||
},
|
||||
});
|
||||
|
||||
expectAttributesToMatch(attributesOfTheOrg, [attr1, attr2, attr3]);
|
||||
});
|
||||
|
||||
it("should return contains correctly for group options that have sub-options not assigned directly to the user", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
const attr1 = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Department",
|
||||
@@ -304,15 +328,16 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
|
||||
await getAttributesAssignmentData({ teamId: team.id, orgId });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0]).toEqual({
|
||||
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
|
||||
expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({
|
||||
userId: user.id,
|
||||
attributes: {
|
||||
attr1: {
|
||||
@@ -332,12 +357,15 @@ describe("getAttributes", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectAttributesToMatch(attributesOfTheOrg, [attr1]);
|
||||
});
|
||||
|
||||
it("should return no attributes for a different org", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const otherTeam = await createMockTeam({ orgId: 1002 });
|
||||
const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
const otherOrgId = 1002;
|
||||
const otherOrgsTeam = await createMockTeam({ orgId: otherOrgId });
|
||||
const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -362,24 +390,27 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
// Assigning a group option that has its sub-options not assigned directly to the user
|
||||
attributeOptionId: "engineering-and-sales-id",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: otherTeam.id });
|
||||
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
|
||||
await getAttributesAssignmentData({ teamId: otherOrgsTeam.id, orgId: otherOrgId });
|
||||
|
||||
expect(teamMembers).toHaveLength(0);
|
||||
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(0);
|
||||
// Because created attribute is of other org, it should not be returned
|
||||
expect(attributesOfTheOrg).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle multi-select attributes correctly", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await createMockAttribute({
|
||||
const attr1 = await createMockAttribute({
|
||||
orgId,
|
||||
id: "attr1",
|
||||
name: "Skills",
|
||||
@@ -393,28 +424,31 @@ describe("getAttributes", () => {
|
||||
|
||||
// Assign multiple skills
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
attributeOptionId: "opt2",
|
||||
});
|
||||
|
||||
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
|
||||
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
|
||||
await getAttributesAssignmentData({ teamId: team.id, orgId });
|
||||
|
||||
expect(teamMembers).toHaveLength(1);
|
||||
expect(teamMembers[0].attributes.attr1.attributeOption).toEqual([
|
||||
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
|
||||
expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr1.attributeOption).toEqual([
|
||||
{ value: "JavaScript", isGroup: false, contains: [] },
|
||||
{ value: "Python", isGroup: false, contains: [] },
|
||||
]);
|
||||
|
||||
expectAttributesToMatch(attributesOfTheOrg, [attr1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsersAttributes", () => {
|
||||
it("should return attributes assigned to specific user in team", async () => {
|
||||
const team = await createMockTeam({ orgId });
|
||||
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
|
||||
orgId,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -429,7 +463,7 @@ describe("getAttributes", () => {
|
||||
});
|
||||
|
||||
await createMockAttributeAssignment({
|
||||
membershipId: teamMembership.id,
|
||||
orgMembershipId: orgMembership.id,
|
||||
attributeOptionId: "opt1",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,32 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AttributeToUser } from "@calcom/prisma/client";
|
||||
import type { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
import { AttributeRepository } from "../../../server/repository/attribute";
|
||||
import { AttributeToUserRepository } from "../../../server/repository/attributeToUser";
|
||||
import { MembershipRepository } from "../../../server/repository/membership";
|
||||
import type { AttributeId } from "../types";
|
||||
import { findAllAttributesWithTheirOptions } from "./utils";
|
||||
|
||||
type UserId = number;
|
||||
type OrgMembershipId = number;
|
||||
type AttributeOptionId = string;
|
||||
type AssignmentForTheTeam = {
|
||||
userId: number;
|
||||
attributeOption: {
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
contains: string[];
|
||||
isGroup: boolean;
|
||||
};
|
||||
attribute: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AttributeType;
|
||||
};
|
||||
};
|
||||
|
||||
export type Attribute = {
|
||||
name: string;
|
||||
@@ -21,6 +41,20 @@ export type Attribute = {
|
||||
}[];
|
||||
};
|
||||
|
||||
type FullAttribute = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: AttributeType;
|
||||
id: string;
|
||||
options: {
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
isGroup: boolean;
|
||||
contains: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type AttributeOptionValue = {
|
||||
value: string;
|
||||
isGroup: boolean;
|
||||
@@ -36,122 +70,17 @@ export type AttributeOptionValueWithType = {
|
||||
attributeOption: AttributeOptionValue | AttributeOptionValue[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to
|
||||
*/
|
||||
async function getAssignedAttributeOptions({ teamId }: { teamId: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAssignedAttributeOptions"] });
|
||||
const whereClauseForAssignment = {
|
||||
member: {
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAssignment,
|
||||
})
|
||||
);
|
||||
|
||||
const assignedAttributeOptions = await prisma.attributeToUser.findMany({
|
||||
where: whereClauseForAssignment,
|
||||
select: {
|
||||
member: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
attributeOption: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
attribute: {
|
||||
select: { id: true, name: true, type: true, slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log.debug("Returned assignedAttributeOptions", safeStringify({ assignedAttributeOptions }));
|
||||
return assignedAttributeOptions;
|
||||
}
|
||||
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
options: {
|
||||
some: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
})
|
||||
);
|
||||
|
||||
const assignedAttributeOptions = await prisma.attribute.findMany({
|
||||
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
return assignedAttributeOptions;
|
||||
}
|
||||
|
||||
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
|
||||
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
|
||||
return attributes satisfies Attribute[];
|
||||
}
|
||||
|
||||
export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
|
||||
const [assignedAttributeOptions, allAttributesWithTheirOptions] = await Promise.all([
|
||||
getAssignedAttributeOptions({ teamId }),
|
||||
// We need to fetch even the unassigned options, to know what all sub-options are there in an option group. Because it is possible that sub-options aren't assigned directly to the user
|
||||
findAllAttributesWithTheirOptions({ teamId }),
|
||||
]);
|
||||
|
||||
const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => {
|
||||
const { userId } = attributeToUser.member;
|
||||
const { attribute, ...attributeOption } = attributeToUser.attributeOption;
|
||||
function _prepareAssignmentData({
|
||||
assignmentsForTheTeam,
|
||||
attributesOfTheOrg,
|
||||
}: {
|
||||
assignmentsForTheTeam: AssignmentForTheTeam[];
|
||||
attributesOfTheOrg: Attribute[];
|
||||
}) {
|
||||
const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce((acc, attributeToUser) => {
|
||||
const userId = attributeToUser.userId;
|
||||
const attributeOption = attributeToUser.attributeOption;
|
||||
const attribute = attributeToUser.attribute;
|
||||
|
||||
if (!acc[userId]) {
|
||||
acc[userId] = { userId, attributes: {} };
|
||||
@@ -208,7 +137,7 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
|
||||
}) {
|
||||
return contains
|
||||
.map((optionId) => {
|
||||
const allOptions = allAttributesWithTheirOptions.get(attribute.id);
|
||||
const allOptions = attributesOfTheOrg.find((_attribute) => _attribute.id === attribute.id)?.options;
|
||||
const option = allOptions?.find((option) => option.id === optionId);
|
||||
if (!option) {
|
||||
console.error(
|
||||
@@ -228,6 +157,210 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
|
||||
}
|
||||
}
|
||||
|
||||
function _getAttributeFromAttributeOption({
|
||||
allAttributesOfTheOrg,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
allAttributesOfTheOrg: Attribute[];
|
||||
attributeOptionId: AttributeOptionId;
|
||||
}) {
|
||||
return allAttributesOfTheOrg.find((attribute) =>
|
||||
attribute.options.some((option) => option.id === attributeOptionId)
|
||||
);
|
||||
}
|
||||
|
||||
function _getAttributeOptionFromAttributeOption({
|
||||
allAttributesOfTheOrg,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
allAttributesOfTheOrg: FullAttribute[];
|
||||
attributeOptionId: AttributeOptionId;
|
||||
}) {
|
||||
const matchingOption = allAttributesOfTheOrg.reduce((found, attribute) => {
|
||||
if (found) return found;
|
||||
return attribute.options.find((option) => option.id === attributeOptionId) || null;
|
||||
}, null as null | (typeof allAttributesOfTheOrg)[number]["options"][number]);
|
||||
return matchingOption;
|
||||
}
|
||||
|
||||
async function _getOrgMembershipToUserIdForTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
|
||||
const { orgMemberships, teamMemberships } = await MembershipRepository.findMembershipsForBothOrgAndTeam({
|
||||
orgId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Using map for performance lookup as it matters in the below loop working with 1000s of records
|
||||
const orgMembershipsByUserId = new Map(orgMemberships.map((m) => [m.userId, m]));
|
||||
|
||||
/**
|
||||
* Holds the records of orgMembershipId to userId for the sub-team's members only.
|
||||
*/
|
||||
const orgMembershipToUserIdForTeamMembers = new Map<OrgMembershipId, UserId>();
|
||||
|
||||
/**
|
||||
* For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300
|
||||
* So, these are not a lot of records and we could afford to do in memory computations on them.
|
||||
*/
|
||||
teamMemberships.forEach((teamMembership) => {
|
||||
const orgMembership = orgMembershipsByUserId.get(teamMembership.userId);
|
||||
if (!orgMembership) {
|
||||
console.error(
|
||||
`Org membership not found for userId ${teamMembership.userId} in the organization's memberships`
|
||||
);
|
||||
return;
|
||||
}
|
||||
orgMembershipToUserIdForTeamMembers.set(orgMembership.id, orgMembership.userId);
|
||||
});
|
||||
|
||||
return orgMembershipToUserIdForTeamMembers;
|
||||
}
|
||||
|
||||
async function _queryAllData({ orgId, teamId }: { orgId: number; teamId: number }) {
|
||||
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([
|
||||
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
|
||||
AttributeRepository.findManyByOrgId({ orgId }),
|
||||
]);
|
||||
|
||||
const orgMembershipIds = Array.from(orgMembershipToUserIdForTeamMembers.keys());
|
||||
|
||||
// Get all the attributes assigned to the members of the team
|
||||
const attributesToUsersForTeam = await AttributeToUserRepository.findManyByOrgMembershipIds({
|
||||
orgMembershipIds,
|
||||
});
|
||||
|
||||
return {
|
||||
attributesOfTheOrg,
|
||||
attributesToUsersForTeam,
|
||||
orgMembershipToUserIdForTeamMembers,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
|
||||
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
|
||||
|
||||
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
||||
options: {
|
||||
some: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
user: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
log.debug(
|
||||
safeStringify({
|
||||
teamId,
|
||||
whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
})
|
||||
);
|
||||
|
||||
const assignedAttributeOptions = await prisma.attribute.findMany({
|
||||
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
return assignedAttributeOptions;
|
||||
}
|
||||
|
||||
function _buildAssignmentsForTeam({
|
||||
attributesToUsersForTeam,
|
||||
orgMembershipToUserIdForTeamMembers,
|
||||
attributesOfTheOrg,
|
||||
}: {
|
||||
attributesToUsersForTeam: AttributeToUser[];
|
||||
orgMembershipToUserIdForTeamMembers: Map<OrgMembershipId, UserId>;
|
||||
attributesOfTheOrg: FullAttribute[];
|
||||
}) {
|
||||
return attributesToUsersForTeam
|
||||
.map((attributeToUser) => {
|
||||
const orgMembershipId = attributeToUser.memberId;
|
||||
const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId);
|
||||
if (!userId) {
|
||||
console.error(`No org membership found for membership id ${orgMembershipId}`);
|
||||
return null;
|
||||
}
|
||||
const attribute = _getAttributeFromAttributeOption({
|
||||
allAttributesOfTheOrg: attributesOfTheOrg,
|
||||
attributeOptionId: attributeToUser.attributeOptionId,
|
||||
});
|
||||
|
||||
const attributeOption = _getAttributeOptionFromAttributeOption({
|
||||
allAttributesOfTheOrg: attributesOfTheOrg,
|
||||
attributeOptionId: attributeToUser.attributeOptionId,
|
||||
});
|
||||
|
||||
if (!attributeOption || !attribute) {
|
||||
console.error(
|
||||
`Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...attributeToUser,
|
||||
userId,
|
||||
attribute,
|
||||
attributeOption,
|
||||
};
|
||||
})
|
||||
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null);
|
||||
}
|
||||
|
||||
export async function getAttributesAssignmentData({ orgId, teamId }: { orgId: number; teamId: number }) {
|
||||
const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserIdForTeamMembers } =
|
||||
await _queryAllData({
|
||||
orgId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const assignmentsForTheTeam = _buildAssignmentsForTeam({
|
||||
attributesToUsersForTeam,
|
||||
orgMembershipToUserIdForTeamMembers,
|
||||
attributesOfTheOrg,
|
||||
});
|
||||
|
||||
const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({
|
||||
attributesOfTheOrg,
|
||||
assignmentsForTheTeam,
|
||||
});
|
||||
|
||||
return {
|
||||
attributesOfTheOrg,
|
||||
attributesAssignedToTeamMembersWithOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
|
||||
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
|
||||
return attributes satisfies Attribute[];
|
||||
}
|
||||
|
||||
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
|
||||
}
|
||||
|
||||
@@ -1,70 +1,4 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { AttributeToUserRepository } from "../../../server/repository/attributeToUser";
|
||||
import type { AttributeId } from "../types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
|
||||
|
||||
async function findTeamById({ teamId }: { teamId: number }) {
|
||||
return prisma.team.findUnique({ where: { id: teamId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the options for all the attributes for a team
|
||||
*/
|
||||
const findAllAttributeOptions = async ({ teamId }: { teamId: number }) => {
|
||||
const team = await findTeamById({ teamId });
|
||||
|
||||
// A non-org team doesn't have attributes
|
||||
if (!team || !team.parentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: team.parentId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
contains: true,
|
||||
isGroup: true,
|
||||
attribute: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures all attributes are their with all their options(only assigned options) mapped to them
|
||||
*/
|
||||
export const findAllAttributesWithTheirOptions = async ({ teamId }: { teamId: number }) => {
|
||||
const allOptionsOfAllAttributes = await findAllAttributeOptions({ teamId });
|
||||
const attributeOptionsMap = new Map<
|
||||
AttributeId,
|
||||
{
|
||||
id: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
contains: string[];
|
||||
isGroup: boolean;
|
||||
}[]
|
||||
>();
|
||||
allOptionsOfAllAttributes.forEach((_attributeOption) => {
|
||||
const { attribute, ...attributeOption } = _attributeOption;
|
||||
const existingOptionsArray = attributeOptionsMap.get(attribute.id);
|
||||
if (!existingOptionsArray) {
|
||||
attributeOptionsMap.set(attribute.id, [attributeOption]);
|
||||
} else {
|
||||
// We already have the options for this attribute
|
||||
existingOptionsArray.push(attributeOption);
|
||||
}
|
||||
});
|
||||
return attributeOptionsMap;
|
||||
};
|
||||
|
||||
export const getWhereClauseForAttributeOptionsManagedByCalcom = () => {
|
||||
// Neither created nor updated by DSync
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attribute_teamId_idx" ON "Attribute"("teamId");
|
||||
@@ -1618,7 +1618,10 @@ model AttributeOption {
|
||||
model Attribute {
|
||||
id String @id @default(uuid())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
// This is organization
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
// This is organizationId
|
||||
teamId Int
|
||||
|
||||
type AttributeType
|
||||
@@ -1635,12 +1638,17 @@ model Attribute {
|
||||
options AttributeOption[]
|
||||
isWeightsEnabled Boolean @default(false)
|
||||
isLocked Boolean @default(false)
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model AttributeToUser {
|
||||
id String @id @default(uuid())
|
||||
|
||||
member Membership @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
// This is the membership of the organization
|
||||
member Membership @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
// This is the membership id of the organization
|
||||
memberId Int
|
||||
|
||||
attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -227,6 +227,8 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const javascriptEventId = teamEvents.find((event) => event.slug === "team-javascript")?.id;
|
||||
const salesEventId = teamEvents.find((event) => event.slug === "team-sales")?.id;
|
||||
const insightsMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
|
||||
@@ -426,7 +426,9 @@ export async function seedAttributes(teamId: number) {
|
||||
export async function seedRoutingForms(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
attributeRaw: { id: string; options: { id: string; value: string }[] }[]
|
||||
attributeRaw: { id: string; options: { id: string; value: string }[] }[],
|
||||
javascriptEventId: number,
|
||||
salesEventId: number
|
||||
) {
|
||||
const seededForm = {
|
||||
id: "948ae412-d995-4865-885a-48302588de03",
|
||||
@@ -435,10 +437,12 @@ export async function seedRoutingForms(
|
||||
{
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
value: "team/insights-team/team-javascript",
|
||||
eventTypeId: javascriptEventId,
|
||||
},
|
||||
{
|
||||
id: "8b2224b2-89ab-4cde-b012-31823f708642",
|
||||
value: "team/insights-team/team-sales",
|
||||
eventTypeId: salesEventId,
|
||||
},
|
||||
],
|
||||
formFieldLocation: {
|
||||
@@ -481,7 +485,7 @@ export async function seedRoutingForms(
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: seededForm.routes[0].value,
|
||||
eventTypeId: 1133,
|
||||
eventTypeId: seededForm.routes[0].eventTypeId,
|
||||
},
|
||||
queryValue: {
|
||||
id: "aaba9988-cdef-4012-b456-719300f53ef8",
|
||||
@@ -512,7 +516,7 @@ export async function seedRoutingForms(
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: seededForm.routes[1].value,
|
||||
eventTypeId: 1133,
|
||||
eventTypeId: seededForm.routes[1].eventTypeId,
|
||||
},
|
||||
queryValue: {
|
||||
id: "aaba9948-cdef-4012-b456-719300f53ef8",
|
||||
|
||||
+43
-33
@@ -298,46 +298,56 @@ async function createOrganizationAndAddMembersAndTeams({
|
||||
})[] = [];
|
||||
|
||||
try {
|
||||
for (const member of orgMembers) {
|
||||
const newUser = await createUserAndEventType({
|
||||
user: {
|
||||
...member.memberData,
|
||||
password: member.memberData.password.create?.hash,
|
||||
},
|
||||
eventTypes: [
|
||||
{
|
||||
title: "30min",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
_bookings: [
|
||||
const batchSize = 50;
|
||||
// Process members in batches of in parallel
|
||||
for (let i = 0; i < orgMembers.length; i += batchSize) {
|
||||
const batch = orgMembers.slice(i, i + batchSize);
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (member) => {
|
||||
const newUser = await createUserAndEventType({
|
||||
user: {
|
||||
...member.memberData,
|
||||
password: member.memberData.password.create?.hash,
|
||||
},
|
||||
eventTypes: [
|
||||
{
|
||||
uid: uuid(),
|
||||
title: "30min",
|
||||
startTime: dayjs().add(1, "day").toDate(),
|
||||
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
_bookings: [
|
||||
{
|
||||
uid: uuid(),
|
||||
title: "30min",
|
||||
startTime: dayjs().add(1, "day").toDate(),
|
||||
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const orgMemberInDb = {
|
||||
...newUser,
|
||||
inTeams: member.inTeams,
|
||||
orgMembership: member.orgMembership,
|
||||
orgProfile: member.orgProfile,
|
||||
};
|
||||
const orgMemberInDb = {
|
||||
...newUser,
|
||||
inTeams: member.inTeams,
|
||||
orgMembership: member.orgMembership,
|
||||
orgProfile: member.orgProfile,
|
||||
};
|
||||
|
||||
await prisma.tempOrgRedirect.create({
|
||||
data: {
|
||||
fromOrgId: 0,
|
||||
type: RedirectType.User,
|
||||
from: member.memberData.username,
|
||||
toUrl: `${getOrgFullOrigin(orgData.slug)}/${member.orgProfile.username}`,
|
||||
},
|
||||
});
|
||||
await prisma.tempOrgRedirect.create({
|
||||
data: {
|
||||
fromOrgId: 0,
|
||||
type: RedirectType.User,
|
||||
from: member.memberData.username,
|
||||
toUrl: `${getOrgFullOrigin(orgData.slug)}/${member.orgProfile.username}`,
|
||||
},
|
||||
});
|
||||
|
||||
orgMembersInDb.push(orgMemberInDb);
|
||||
return orgMemberInDb;
|
||||
})
|
||||
);
|
||||
|
||||
orgMembersInDb.push(...batchResults);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
+6
@@ -18,9 +18,14 @@ interface FindTeamMembersMatchingAttributeLogicHandlerOptions {
|
||||
}
|
||||
|
||||
export const findTeamMembersMatchingAttributeLogicHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => {
|
||||
const { teamId, attributesQueryValue, _enablePerf, _concurrency } = input;
|
||||
const orgId = ctx.user.organizationId;
|
||||
if (!orgId) {
|
||||
throw new Error("You must be in an organization to use this feature");
|
||||
}
|
||||
const {
|
||||
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
|
||||
mainAttributeLogicBuildingWarnings: mainWarnings,
|
||||
@@ -30,6 +35,7 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
|
||||
{
|
||||
teamId,
|
||||
attributesQueryValue,
|
||||
orgId,
|
||||
},
|
||||
{
|
||||
enablePerf: _enablePerf,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import type { TRPCContext } from "../../../createContext";
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TResendVerifyEmailSchema } from "./resendVerifyEmail.schema";
|
||||
|
||||
type ResendEmailOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
req: TRPCContext["req"] | undefined;
|
||||
};
|
||||
input: TResendVerifyEmailSchema;
|
||||
};
|
||||
@@ -17,6 +23,13 @@ const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
|
||||
|
||||
export const resendVerifyEmail = async ({ input, ctx }: ResendEmailOptions) => {
|
||||
let emailToVerify = ctx.user.email;
|
||||
const identifer = ctx.req ? getIP(ctx.req as NextApiRequest) : emailToVerify;
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `resendVerifyEmail.${identifer}`,
|
||||
});
|
||||
|
||||
let emailVerified = Boolean(ctx.user.emailVerified);
|
||||
let secondaryEmail;
|
||||
// If the input which is coming is not the current user's email, it could be a secondary email
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import publicProcedure from "../../../procedures/publicProcedure";
|
||||
import { router, importHandler } from "../../../trpc";
|
||||
import { ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema";
|
||||
import { ZResponseInputSchema } from "./response.schema";
|
||||
|
||||
const NAMESPACE = "routingForms";
|
||||
|
||||
@@ -16,4 +18,11 @@ export const routingFormsRouter = router({
|
||||
);
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
public: router({
|
||||
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const handler = await importHandler(namespaced("response"), () => import("./response.handler"));
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
+10
-13
@@ -17,6 +17,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "@calcom/routing-forms/getEventTypeRedirectUrl";
|
||||
import { getSerializableForm } from "@calcom/routing-forms/lib/getSerializableForm";
|
||||
import { getServerTimingHeader } from "@calcom/routing-forms/lib/getServerTimingHeader";
|
||||
import isRouter from "@calcom/routing-forms/lib/isRouter";
|
||||
import { RouteActionType } from "@calcom/routing-forms/zod";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
@@ -112,6 +113,14 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const formOrgId = form.team?.parentId;
|
||||
if (!formOrgId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "This form is not associated with an organization",
|
||||
});
|
||||
}
|
||||
|
||||
const beforeEnrichedForm = performance.now();
|
||||
const serializableForm = await getEnrichedSerializableForm(form);
|
||||
const afterEnrichedForm = performance.now();
|
||||
@@ -189,6 +198,7 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
|
||||
attributesQueryValue: route.attributesQueryValue ?? null,
|
||||
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue ?? null,
|
||||
teamId: form.teamId,
|
||||
orgId: formOrgId,
|
||||
isPreview: !!isPreview,
|
||||
},
|
||||
{
|
||||
@@ -310,17 +320,4 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
|
||||
};
|
||||
};
|
||||
|
||||
function getServerTimingHeader(timeTaken: Record<string, number | null | undefined>) {
|
||||
const headerParts = Object.entries(timeTaken)
|
||||
.map(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
return `${key};dur=${value}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return headerParts.join(", ");
|
||||
}
|
||||
|
||||
export default findTeamMembersMatchingAttributeLogicOfRouteHandler;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import type { TResponseInputSchema } from "./response.schema";
|
||||
|
||||
interface ResponseHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TResponseInputSchema;
|
||||
}
|
||||
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const { formId, response, formFillerId, chosenRouteId = null } = input;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm({
|
||||
form,
|
||||
});
|
||||
|
||||
return handleResponse({ response, form: serializableForm, formFillerId, chosenRouteId });
|
||||
};
|
||||
|
||||
export default responseHandler;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GetAvailabilityUser } from "@calcom/core/getUserAvailability";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
import { checkIfIsAvailable } from "./util";
|
||||
import { getUsersWithCredentialsConsideringContactOwner } from "./util";
|
||||
|
||||
describe("getUsersWithCredentialsConsideringContactOwner", () => {
|
||||
@@ -88,3 +91,163 @@ describe("getUsersWithCredentialsConsideringContactOwner", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkIfIsAvailable", () => {
|
||||
const createTestData = (time: string) => ({
|
||||
time: dayjs(time),
|
||||
eventLength: 30,
|
||||
busy: [] as EventBusyDate[],
|
||||
});
|
||||
|
||||
describe("currentSeats handling", () => {
|
||||
it("should return true if slot exists in currentSeats", () => {
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "123",
|
||||
startTime: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
currentSeats,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("busy time overlap scenarios", () => {
|
||||
it("should return true when no busy periods", () => {
|
||||
const result = checkIfIsAvailable(createTestData("2023-01-01T09:00:00Z"));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when busy period ends before slot starts", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T08:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when busy period starts after slot ends", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when slot start falls within busy period", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:15:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when slot end falls within busy period", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T08:45:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when busy period is completely contained within slot", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
eventLength: 60,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when slot is completely contained within busy period", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:15:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when multiple non-overlapping busy periods", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if any busy period overlaps", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle exact boundary conditions", () => {
|
||||
const result = checkIfIsAvailable({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,33 +62,38 @@ export const checkIfIsAvailable = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
const slotEndTime = time.add(eventLength, "minutes").utc();
|
||||
const slotStartTime = time.utc();
|
||||
const slotStartDate = time.utc().toDate();
|
||||
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
|
||||
|
||||
return busy.every((busyTime) => {
|
||||
const startTime = dayjs.utc(busyTime.start).utc();
|
||||
const endTime = dayjs.utc(busyTime.end);
|
||||
const busyStartDate = dayjs.utc(busyTime.start).toDate();
|
||||
const busyEndDate = dayjs.utc(busyTime.end).toDate();
|
||||
|
||||
if (endTime.isBefore(slotStartTime) || startTime.isAfter(slotEndTime)) {
|
||||
// First check if there's any overlap at all
|
||||
// If busy period ends before slot starts or starts after slot ends, there's no overlap
|
||||
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (slotStartTime.isBetween(startTime, endTime, null, "[)")) {
|
||||
return false;
|
||||
} else if (slotEndTime.isBetween(startTime, endTime, null, "(]")) {
|
||||
// Now check all possible overlap scenarios:
|
||||
|
||||
// 1. Slot start falls within busy period (inclusive start, exclusive end)
|
||||
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if start times are the same
|
||||
if (time.utc().isBetween(startTime, endTime, null, "[)")) {
|
||||
// 2. Slot end falls within busy period (exclusive start, inclusive end)
|
||||
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
|
||||
return false;
|
||||
}
|
||||
// Check if slot end time is between start and end time
|
||||
else if (slotEndTime.isBetween(startTime, endTime)) {
|
||||
|
||||
// 3. Busy period completely contained within slot
|
||||
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
|
||||
return false;
|
||||
}
|
||||
// Check if startTime is between slot
|
||||
else if (startTime.isBetween(time, slotEndTime)) {
|
||||
|
||||
// 4. Slot completely contained within busy period
|
||||
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -986,22 +991,18 @@ const calculateHostsAndAvailabilities = async ({
|
||||
};
|
||||
|
||||
const allUserIds = usersWithCredentials.map((user) => user.id);
|
||||
const currentBookingsAllUsers = await monitorCallbackAsync(
|
||||
getExistingBookings,
|
||||
startTimeDate,
|
||||
endTimeDate,
|
||||
eventType,
|
||||
sharedQuery,
|
||||
usersWithCredentials,
|
||||
allUserIds
|
||||
);
|
||||
|
||||
const outOfOfficeDaysAllUsers = await monitorCallbackAsync(
|
||||
getOOODates,
|
||||
startTimeDate,
|
||||
endTimeDate,
|
||||
allUserIds
|
||||
);
|
||||
const [currentBookingsAllUsers, outOfOfficeDaysAllUsers] = await Promise.all([
|
||||
monitorCallbackAsync(
|
||||
getExistingBookings,
|
||||
startTimeDate,
|
||||
endTimeDate,
|
||||
eventType,
|
||||
sharedQuery,
|
||||
usersWithCredentials,
|
||||
allUserIds
|
||||
),
|
||||
monitorCallbackAsync(getOOODates, startTimeDate, endTimeDate, allUserIds),
|
||||
]);
|
||||
|
||||
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
||||
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
||||
|
||||
+2
-1
@@ -119,7 +119,8 @@
|
||||
"PLAIN_API_KEY",
|
||||
"PLAIN_API_URL",
|
||||
"PLAIN_CHAT_ID",
|
||||
"PLAIN_CHAT_HMAC_SECRET_KEY"
|
||||
"PLAIN_CHAT_HMAC_SECRET_KEY",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user