Compare commits

...
13 Commits
Author SHA1 Message Date
GitHub Actions 8ffebdba44 chore: release v4.8.10
Post release / release (push) Canceled after 0s
2024-12-27 21:07:33 +00:00
Udit TakkarandBailey Pumfleet cc1101ac43 refactor: email handler (#18391)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-12-27 21:02:05 +00:00
Anik Dhabal BabuandUdit Takkar 7a80a5f929 fix: localized date (#18388)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-27 20:59:53 +00:00
NizzyandUdit Takkar a27e1498b8 fix: removed plain chat from app.cal.com/video (#18385)
* fix: removed plain chat from app.cal.com/video

* take user session and open plain out of use effect

* move undefined check and screen size check above useeffect

* turned restrcited paths into env var

* undefeined window below hooks

* chore: add window check

* fix: infinite re rendering

* remove console.log

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-12-27 18:42:36 +00:00
Calcom BotandReplexica a697857fa0 feat: update translations via @replexica (#18389)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-27 17:33:26 +00:00
Udit Takkar e2f45bdf5a perf: improve checkIsAvailable in getAvailable slots (#18352)
* refactor: Update booking query to use userId and optimize availability check logic

- Changed the booking query to use userId instead of nested user object for filtering active bookings.
- Refactored availability check logic to improve readability and performance by using valueOf() for time comparisons and consolidating multiple checks into a single return statement.

* fix: update tests

* chore: use toDate()

* tests: add checkavailable unit tests
2024-12-27 16:01:54 +00:00
Hariom Balhara d2930bc243 fix: Handle more dry-run cases (#18286) 2024-12-27 16:23:14 +01:00
GitHub Actions b0b93f612e chore: release v4.8.9
Post release / release (push) Canceled after 0s
2024-12-27 11:17:18 +00:00
Udit Takkar 558c8be740 perf: getAvailableSlots improvements (#18382) 2024-12-27 11:09:49 +00:00
Alex van Andel 1aee1742d5 chore: rely less on autoinfer, be able to use initial data types in consumers (#18359) 2024-12-27 16:23:49 +05:30
Hariom BalharaandUdit Takkar ab942ed4cd Fix routing form field input loosing focus on typing (#18381)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-27 14:30:10 +05:30
Hariom Balhara b5bec79354 perf: /router endpoint perf improvement (#18366) 2024-12-27 08:56:52 +00:00
Nizzy b48d0e1f48 remove yarnlock (#18377) 2024-12-27 00:44:21 -05:00
98 changed files with 1656 additions and 821 deletions
+1
View File
@@ -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=
View File
+2
View File
@@ -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
View File
@@ -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>
);
}
-4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.8.8",
"version": "4.8.10",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+6
View File
@@ -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;
@@ -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": "欄位類型",
@@ -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 {
+3
View File
@@ -23,6 +23,9 @@
},
"pages/api/trpc/appRoutingForms/[trpc].ts": {
"memory": 2048
},
"pages/router/embed.tsx": {
"memory": 2048
}
}
}
+3 -2
View File
@@ -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);
});
});
});
@@ -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(", ");
}
@@ -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"));
+49 -47
View File
@@ -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;
+1
View File
@@ -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: [
{
+3 -2
View File
@@ -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>>({
+20 -9
View File
@@ -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;
}
}
+2 -4
View File
@@ -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");
+10 -2
View File
@@ -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)
+2
View File
@@ -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,
+7 -3
View File
@@ -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
View File
@@ -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) {
@@ -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 });
}),
}),
});
@@ -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
View File
@@ -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": {