From c41e5ddc2cf4d5abaae875362cca077a1d27a382 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 14 Aug 2024 10:31:01 +0100 Subject: [PATCH 01/12] feat: add gtm to load in us only (#16146) * add gtm to load in us only * fix: Tried to give typescript some help * fix: Alternative fix * fix: Add yarn.lock * fix: Oops * fix: type err --------- Co-authored-by: Alex van Andel Co-authored-by: Udit Takkar --- apps/web/components/GTM.tsx | 54 ++++++++ apps/web/components/PageWrapper.tsx | 3 + apps/web/pages/api/geolocation.ts | 7 ++ package.json | 1 + .../reminders/providers/sendgridProvider.ts | 9 +- yarn.lock | 117 ++++++++++++++---- 6 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 apps/web/components/GTM.tsx create mode 100644 apps/web/pages/api/geolocation.ts diff --git a/apps/web/components/GTM.tsx b/apps/web/components/GTM.tsx new file mode 100644 index 00000000000000..f91d5a1d6bd6ab --- /dev/null +++ b/apps/web/components/GTM.tsx @@ -0,0 +1,54 @@ +import { GoogleTagManager } from "@next/third-parties/google"; +import { useQuery } from "@tanstack/react-query"; + +const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; + +const CACHE_KEY = "user_geolocation"; +const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + +export async function fetchGeolocation() { + const cachedData = localStorage.getItem(CACHE_KEY); + + if (cachedData) { + const { country, timestamp } = JSON.parse(cachedData); + + if (Date.now() - timestamp < CACHE_DURATION) { + return { country }; + } + } + + const res = await fetch("/api/geolocation"); + const data = await res.json(); + + const newCacheData = { + country: data.country, + timestamp: Date.now(), + }; + + localStorage.setItem(CACHE_KEY, JSON.stringify(newCacheData)); + return data; +} + +export function useGeolocation() { + const { data, isLoading, error } = useQuery({ + queryKey: ["geolocation"], + queryFn: fetchGeolocation, + staleTime: 24 * 60 * 60 * 1000, // 24 hours + }); + + return { + isUS: data?.country === "US", + loading: isLoading, + error, + }; +} + +export function GoogleTagManagerComponent() { + const { isUS, loading } = useGeolocation(); + + if (!isUS || !GTM_ID || loading) { + return null; + } + + return ; +} diff --git a/apps/web/components/PageWrapper.tsx b/apps/web/components/PageWrapper.tsx index 2250d84b151c16..c3f0a0788321bc 100644 --- a/apps/web/components/PageWrapper.tsx +++ b/apps/web/components/PageWrapper.tsx @@ -13,6 +13,8 @@ import type { AppProps } from "@lib/app-providers"; import AppProviders from "@lib/app-providers"; import { seoConfig } from "@lib/config/next-seo.config"; +import { GoogleTagManagerComponent } from "@components/GTM"; + export interface CalPageWrapper { (props?: AppProps): JSX.Element; PageWrapper?: AppProps["Component"]["PageWrapper"]; @@ -93,6 +95,7 @@ function PageWrapper(props: AppProps) { ) )} + ); } diff --git a/apps/web/pages/api/geolocation.ts b/apps/web/pages/api/geolocation.ts new file mode 100644 index 00000000000000..b5d61d6481b7fe --- /dev/null +++ b/apps/web/pages/api/geolocation.ts @@ -0,0 +1,7 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const country = req.headers["x-vercel-ip-country"] || "Unknown"; + res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"); + res.status(200).json({ country }); +} diff --git a/package.json b/package.json index 42be9cf6945937..2ab82dcd27ffbf 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ }, "dependencies": { "@daily-co/daily-js": "^0.59.0", + "@next/third-parties": "^14.2.5", "@vercel/functions": "^1.4.0", "city-timezones": "^1.2.1", "eslint": "^8.34.0", diff --git a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts index 29357ec21c79b2..e6a3cac99c63c2 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts @@ -121,15 +121,12 @@ function addHTMLStyles(html?: string) { return ""; } const dom = new JSDOM(html); - const document = dom.window.document; - // Select all tags inside
elements --> only used for emojis in rating template - const links = document.querySelectorAll("h6 a"); + const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement); links.forEach((link) => { - const htmlLink = link as HTMLElement; - htmlLink.style.fontSize = "20px"; - htmlLink.style.textDecoration = "none"; + link.style.fontSize = "20px"; + link.style.textDecoration = "none"; }); return dom.serialize(); diff --git a/yarn.lock b/yarn.lock index fdb8746c5f3f2f..8bff7c23bad957 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4133,8 +4133,8 @@ __metadata: resolution: "@calcom/api-v2@workspace:apps/api/v2" dependencies: "@calcom/platform-constants": "*" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.27" "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2" - "@calcom/platform-libraries-0.0.26": "npm:@calcom/platform-libraries@0.0.26" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -4465,17 +4465,17 @@ __metadata: "@heroicons/react": ^1.0.6 "@prisma/client": ^5.4.2 "@tailwindcss/forms": ^0.5.2 - "@types/node": ^20.3.1 + "@types/node": 16.9.1 "@types/react": 18.0.26 - autoprefixer: ^10.4.19 + autoprefixer: ^10.4.12 chart.js: ^3.7.1 client-only: ^0.0.1 eslint: ^8.34.0 - next: ^14.1.3 + next: ^13.5.4 next-auth: ^4.22.1 next-i18next: ^13.2.2 - postcss: ^8.4.38 - prisma: ^5.7.1 + postcss: ^8.4.18 + prisma: ^5.4.2 prisma-field-encryption: ^1.4.0 react: ^18.2.0 react-chartjs-2: ^4.0.1 @@ -4484,7 +4484,7 @@ __metadata: react-live-chat-loader: ^2.8.1 swr: ^1.2.2 tailwindcss: ^3.3.3 - typescript: ^5.3.3 + typescript: ^4.9.4 zod: ^3.22.4 languageName: unknown linkType: soft @@ -4849,6 +4849,15 @@ __metadata: languageName: unknown linkType: soft +"@calcom/horizon-workrooms@workspace:packages/app-store/horizon-workrooms": + version: 0.0.0-use.local + resolution: "@calcom/horizon-workrooms@workspace:packages/app-store/horizon-workrooms" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + languageName: unknown + linkType: soft + "@calcom/hubspot@workspace:packages/app-store/hubspot": version: 0.0.0-use.local resolution: "@calcom/hubspot@workspace:packages/app-store/hubspot" @@ -5086,25 +5095,25 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries-0.0.26@npm:@calcom/platform-libraries@0.0.26": - version: 0.0.26 - resolution: "@calcom/platform-libraries@npm:0.0.26" +"@calcom/platform-libraries-0.0.2@npm:@calcom/platform-libraries@0.0.2, @calcom/platform-libraries@npm:0.0.2": + version: 0.0.2 + resolution: "@calcom/platform-libraries@npm:0.0.2" dependencies: "@calcom/core": "*" "@calcom/features": "*" "@calcom/lib": "*" - checksum: 95b60b6bccfecf1d75f5b37b6e2b48811d6cbdd6ccb8157fa700558f2947adefd1fa26e7ed123f72960ddbf44ead26cce6e6fbd62505d73f5490abcf7c95563d + checksum: 61b6be1b9d0be8a54ca8b1dff4b1e4db122b3c30d9203467f5347232eaf600ca3892da45a2de8abfe75327086406c33c1f6f75cd12a7147744437bc85a0ee755 languageName: node linkType: hard -"@calcom/platform-libraries-0.0.2@npm:@calcom/platform-libraries@0.0.2, @calcom/platform-libraries@npm:0.0.2": - version: 0.0.2 - resolution: "@calcom/platform-libraries@npm:0.0.2" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.27": + version: 0.0.27 + resolution: "@calcom/platform-libraries@npm:0.0.27" dependencies: "@calcom/core": "*" "@calcom/features": "*" "@calcom/lib": "*" - checksum: 61b6be1b9d0be8a54ca8b1dff4b1e4db122b3c30d9203467f5347232eaf600ca3892da45a2de8abfe75327086406c33c1f6f75cd12a7147744437bc85a0ee755 + checksum: 516c347b3f71e45879925d31f067035a7c6857dc0f474a726dcb58a7949e1d6906e8e21b18a3a480052088ef742048029509aa74b6bde31b51a13fc6ea00a9f4 languageName: node linkType: hard @@ -5768,23 +5777,23 @@ __metadata: "@radix-ui/react-tabs": ^1.0.0 "@radix-ui/react-tooltip": ^1.0.0 "@stripe/stripe-js": ^1.35.0 - "@tanstack/react-query": ^5.17.15 + "@tanstack/react-query": ^4.3.9 "@typeform/embed-react": ^1.2.4 "@types/bcryptjs": ^2.4.2 "@types/debounce": ^1.2.1 "@types/gtag.js": ^0.0.10 "@types/micro": 7.3.7 - "@types/node": ^20.3.1 + "@types/node": 16.9.1 "@types/react": 18.0.26 "@types/react-gtm-module": ^2.0.1 "@types/xml2js": ^0.4.11 "@vercel/analytics": ^0.1.6 "@vercel/edge-functions-ui": ^0.2.1 "@vercel/og": ^0.5.0 - autoprefixer: ^10.4.19 + autoprefixer: ^10.4.12 bcryptjs: ^2.4.3 class-variance-authority: ^0.7.0 - clsx: ^2.0.0 + clsx: ^1.2.1 cobe: ^0.4.1 concurrently: ^7.6.0 cross-env: ^7.0.3 @@ -5814,7 +5823,7 @@ __metadata: next-i18next: ^13.2.2 next-seo: ^6.0.0 playwright-core: ^1.38.1 - postcss: ^8.4.38 + postcss: ^8.4.18 prism-react-renderer: ^1.3.5 react: ^18.2.0 react-confetti: ^6.0.1 @@ -5837,14 +5846,14 @@ __metadata: remark: ^14.0.2 remark-html: ^14.0.1 remeda: ^1.24.1 - stripe: ^15.3.0 + stripe: ^9.16.0 tailwind-merge: ^1.13.2 tailwindcss: ^3.3.3 ts-node: ^10.9.1 - typescript: ^5.3.3 + typescript: ^4.9.4 wait-on: ^7.0.1 xml2js: ^0.6.0 - zod: ^3.22.4 + zod: ^3.22.2 languageName: unknown linkType: soft @@ -9822,6 +9831,18 @@ __metadata: languageName: node linkType: hard +"@next/third-parties@npm:^14.2.5": + version: 14.2.5 + resolution: "@next/third-parties@npm:14.2.5" + dependencies: + third-party-capital: 1.0.20 + peerDependencies: + next: ^13.0.0 || ^14.0.0 + react: ^18.2.0 + checksum: e2d32f9d9891c0189c88b2e096e11f6b1e63a6155bee2ebaf1a72531839ea8aa670b039f9d1f68337ad046cdeb429b9cb028676ffd1078e9259341bbda9d5b68 + languageName: node + linkType: hard + "@noble/curves@npm:1.1.0, @noble/curves@npm:~1.1.0": version: 1.1.0 resolution: "@noble/curves@npm:1.1.0" @@ -16161,6 +16182,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:4.36.1": + version: 4.36.1 + resolution: "@tanstack/query-core@npm:4.36.1" + checksum: 47672094da20d89402d9fe03bb7b0462be73a76ff9ca715169738bc600a719d064d106d083a8eedae22a2c22de22f87d5eb5d31ef447aba771d9190f2117ed10 + languageName: node + linkType: hard + "@tanstack/query-core@npm:5.17.19": version: 5.17.19 resolution: "@tanstack/query-core@npm:5.17.19" @@ -16168,6 +16196,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query@npm:^4.3.9": + version: 4.36.1 + resolution: "@tanstack/react-query@npm:4.36.1" + dependencies: + "@tanstack/query-core": 4.36.1 + use-sync-external-store: ^1.2.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 1aff0a476859386f8d32253fa0d0bde7b81769a6d4d4d9cbd78778f0f955459a3bdb7ee27a0d2ee7373090f12998b45df80db0b5b313bd0a7a39d36c6e8e51c5 + languageName: node + linkType: hard + "@tanstack/react-query@npm:^5.17.15": version: 5.17.19 resolution: "@tanstack/react-query@npm:5.17.19" @@ -21345,6 +21392,7 @@ __metadata: dependencies: "@changesets/cli": ^2.26.1 "@daily-co/daily-js": ^0.59.0 + "@next/third-parties": ^14.2.5 "@playwright/test": ^1.45.3 "@snaplet/copycat": ^4.1.0 "@testing-library/jest-dom": ^5.16.5 @@ -22362,6 +22410,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^1.2.1": + version: 1.2.1 + resolution: "clsx@npm:1.2.1" + checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 + languageName: node + linkType: hard + "clsx@npm:^2.0.0": version: 2.1.0 resolution: "clsx@npm:2.1.0" @@ -45361,6 +45416,13 @@ __metadata: languageName: node linkType: hard +"third-party-capital@npm:1.0.20": + version: 1.0.20 + resolution: "third-party-capital@npm:1.0.20" + checksum: ef5eae00bdb82b538b9f628a011fc294cd6f4bafdbb46d88f3d1a72e8c3b9e2cc2a547fdb62bc16bdd847e9da3dac2df676b154c64914f6c90ea15aac6ce0a6a + languageName: node + linkType: hard + "thirty-two@npm:^1.0.2": version: 1.0.2 resolution: "thirty-two@npm:1.0.2" @@ -47375,6 +47437,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.2.0": + version: 1.2.2 + resolution: "use-sync-external-store@npm:1.2.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: fe07c071c4da3645f112c38c0e57beb479a8838616ff4e92598256ecce527f2888c08febc7f9b2f0ce2f0e18540ba3cde41eb2035e4fafcb4f52955037098a81 + languageName: node + linkType: hard + "utif@npm:^2.0.1": version: 2.0.1 resolution: "utif@npm:2.0.1" From 95e2ad300701d582cad7e87230fdf3da7ef34b71 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 14 Aug 2024 07:17:52 -0400 Subject: [PATCH 02/12] fix: Disable all emails per event type w/o a workflow [CAL-4066] (#15902) * Create confirmation dialog for attendees * Add disabling host emails option * Fix DOM issues * Allow updating disabling emails on backend * Pass metadata to sendEmails functions * Clean up * Type fixes * Fix test * Allow for enterprise only * Type fix * Test fix * Check for parentId * Disable emails for sendScheduledEmails and sendScheduledSeatsEmails * Remove unused variable * Only update disable all emails if event type belongs to an org * Disable scheduling mandatory reminder workflow if attendee emails are disabled * Refactor disable email UI * Update copy * Type fixes * Fix missing params --- .../components/eventtype/EventAdvancedTab.tsx | 30 +- .../settings/DisableAllEmailsSetting.tsx | 82 ++++++ apps/web/pages/api/cron/bookingReminder.ts | 4 +- apps/web/public/static/locales/en/common.json | 41 +-- .../stripepayment/lib/PaymentService.ts | 33 ++- packages/app-store/vital/lib/reschedule.ts | 16 +- .../wipemycalother/lib/reschedule.ts | 16 +- packages/emails/email-manager.ts | 262 ++++++++++++------ .../bookings/lib/handleBookingRequested.ts | 12 +- .../bookings/lib/handleCancelBooking.ts | 13 +- .../bookings/lib/handleConfirmation.ts | 19 +- .../features/bookings/lib/handleNewBooking.ts | 44 +-- .../handleSeats/cancel/cancelAttendeeSeat.ts | 16 +- .../lib/handleSeats/create/createNewSeat.ts | 3 +- .../attendeeRescheduleSeatedBooking.ts | 4 +- .../owner/combineTwoSeatedBookings.ts | 13 +- .../owner/moveSeatedBookingToNewTimeSlot.ts | 13 +- .../credentials/handleDeleteCredential.ts | 6 +- packages/features/ee/payments/api/webhook.ts | 4 +- .../ee/round-robin/roundRobinReassignment.ts | 23 +- packages/lib/payment/handlePayment.ts | 2 +- packages/lib/payment/handlePaymentSuccess.ts | 2 +- packages/prisma/zod-utils.ts | 8 + .../loggedInViewer/connectAndJoin.handler.ts | 7 +- .../viewer/bookings/confirm.handler.ts | 3 +- .../viewer/bookings/editLocation.handler.ts | 6 +- .../bookings/requestReschedule.handler.ts | 11 +- .../viewer/eventTypes/update.handler.ts | 28 +- .../trpc/server/routers/viewer/payments.tsx | 7 +- .../viewer/payments/chargeCard.handler.ts | 7 +- packages/types/PaymentService.d.ts | 3 +- 31 files changed, 526 insertions(+), 212 deletions(-) create mode 100644 apps/web/components/eventtype/settings/DisableAllEmailsSetting.tsx diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 39245a06f4b37c..04c4095d035e68 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -41,6 +41,7 @@ import { } from "@calcom/ui"; import RequiresConfirmationController from "./RequiresConfirmationController"; +import { DisableAllEmailsSetting } from "./settings/DisableAllEmailsSetting"; const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal")); @@ -57,7 +58,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick workflowOnEventType.workflow); const selectedThemeIsDark = user?.theme === "dark" || @@ -590,6 +590,34 @@ export const EventAdvancedTab = ({ eventType, team }: Pick )} + {team?.parentId && ( + <> + { + return ( + <> + + + ); + }} + /> + ( + <> + + + )} + /> + + )} {showEventNameTip && ( void; + recipient: "attendees" | "hosts"; + t: TFunction; +} + +export const DisableAllEmailsSetting = ({ + checked, + onCheckedChange, + recipient, + t, +}: DisableEmailsSettingProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + const title = + recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts"); + + return ( +
+ setDialogOpen(e)}> + +

+ + This will disable all emails to {{ recipient }}. This includes booking confirmations, requests, + reschedules and reschedule requests, cancellation emails, and any other emails related to + booking updates. +
+
+ It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and + changes to their bookings. +
+

+

{t("type_confirm_to_continue")}

+ { + setConfirmText(e.target.value); + }} + /> + + + + +
+
+ { + checked ? onCheckedChange(!checked) : setDialogOpen(true); + }} + /> +
+ ); +}; diff --git a/apps/web/pages/api/cron/bookingReminder.ts b/apps/web/pages/api/cron/bookingReminder.ts index 9547bb1eced517..149f372c88c040 100644 --- a/apps/web/pages/api/cron/bookingReminder.ts +++ b/apps/web/pages/api/cron/bookingReminder.ts @@ -7,6 +7,7 @@ import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, ReminderType } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -61,6 +62,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { recurringEvent: true, bookingFields: true, + metadata: true, }, }, responses: true, @@ -130,7 +132,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [], }; - await sendOrganizerRequestReminderEmail(evt); + await sendOrganizerRequestReminderEmail(evt, booking?.eventType?.metadata as EventTypeMetadata); await prisma.reminderMail.create({ data: { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7128212cd9d235..6c3a579e35e8f8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -22,7 +22,7 @@ "verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability", "verify_email_email_header": "Verify your email address", "verify_email_email_button": "Verify email", - "cal_ai_assistant":"Cal AI Assistant", + "cal_ai_assistant": "Cal AI Assistant", "verify_email_change_description": "You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.", "verify_email_change_success_toast": "Updated your email to {{email}}", "verify_email_change_failure_toast": "Failed to update email.", @@ -87,7 +87,7 @@ "missing_card_fields": "Missing card fields", "pay_now": "Pay now", "general_prompt": "General Prompt", - "begin_message":"Begin Message", + "begin_message": "Begin Message", "codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not", "cannot_repackage_codebase": "You can not repackage or sell the codebase", "acquire_license": "Acquire a commercial license to remove these terms by emailing", @@ -118,9 +118,9 @@ "event_still_awaiting_approval": "An event is still waiting for your approval", "booking_submitted_subject": "Booking Submitted: {{title}} at {{date}}", "download_recording_subject": "Download Recording: {{title}} at {{date}}", - "download_transcript_email_subject":"Download Transcript: {{title}} at {{date}}", + "download_transcript_email_subject": "Download Transcript: {{title}} at {{date}}", "download_your_recording": "Download your recording", - "download_your_transcripts":"Download your Transcripts", + "download_your_transcripts": "Download your Transcripts", "your_meeting_has_been_booked": "Your meeting has been booked", "event_type_has_been_rescheduled_on_time_date": "Your {{title}} has been rescheduled to {{date}}.", "event_has_been_rescheduled": "Updated - Your event has been rescheduled", @@ -171,7 +171,7 @@ "seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement", "multilocation_doesnt_support_seats": "Multiple Locations doesn't support seats option", "no_show_fee_doesnt_support_seats": "No show fee doesn't support seats option", - "seats_option_doesnt_support_multi_location" : "Seats option doesn't support Multiple Locations", + "seats_option_doesnt_support_multi_location": "Seats option doesn't support Multiple Locations", "team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.", "team_upgrade_banner_description": "You haven't finished your team setup. Your team \"{{teamName}}\" needs to be upgraded.", "upgrade_banner_action": "Upgrade here", @@ -222,7 +222,7 @@ "2fa_confirm_current_password": "Confirm your current password to get started.", "2fa_scan_image_or_use_code": "Scan the image below with the authenticator app on your phone or manually enter the text code instead.", "text": "Text", - "your_phone_number":"Your Phone Number", + "your_phone_number": "Your Phone Number", "multiline_text": "Multiline Text", "number": "Number", "checkbox": "Checkbox", @@ -1309,7 +1309,7 @@ "upgrade": "Upgrade", "upgrade_to_access_recordings_title": "Upgrade to access recordings", "upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls", - "upgrade_to_cal_ai_phone_number_description":"Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls", + "upgrade_to_cal_ai_phone_number_description": "Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls", "recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan", "team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.", "team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.", @@ -1465,7 +1465,7 @@ "download_transcript": "Download Transcript", "recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download", "transcript_from_previous_call": "Transcript from your recent call on {{appName}} is ready to download. Links are valid only for 1 Hour", - "link_valid_for_12_hrs":"Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here.", + "link_valid_for_12_hrs": "Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here.", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", "create_your_first_webhook": "Create your first Webhook", @@ -2310,7 +2310,7 @@ "dont_want_to_wait": "Don't want to wait?", "meeting_started": "Meeting Started", "pay_and_book": "Pay to book", - "cal_ai_event_tab_description":"Let AI Agents book you", + "cal_ai_event_tab_description": "Let AI Agents book you", "booking_not_found_error": "Could not find booking", "booking_seats_full_error": "Booking seats are full", "missing_payment_credential_error": "Missing payment credentials", @@ -2370,7 +2370,7 @@ "account_unlinked_success": "Account unlinked successfully", "account_unlinked_error": "There was an error unlinking the account", "travel_schedule": "Travel Schedule", - "travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.", + "travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.", "schedule_timezone_change": "Schedule timezone change", "date": "Date", "overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.", @@ -2399,11 +2399,11 @@ "email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_team_invite|content|invited_to_subteam": "{{invitedBy}} has invited you to join the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_team_invite|content|invited_to_regular_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email|existing_user_added_link_will_change":"On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.

Please note: All of your personal event types will be moved into the {teamName} organization, which may also include potential personal link.

For personal events we recommend creating a new account with a personal email address.", - "email|existing_user_added_link_changed":"Your link has been changed from
{prevLinkWithoutProtocol} to {newLinkWithoutProtocol} but don't worry, all previous links still work and redirect appropriately.

Please note: All of your personal event types have been moved into the {teamName} organisation, which may also include potential personal link.

Please log in and make sure you have no private events on your new organisational account.

For personal events we recommend creating a new account with a personal email address.

Enjoy your new clean link: {newLinkWithoutProtocol}", + "email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.

Please note: All of your personal event types will be moved into the {teamName} organization, which may also include potential personal link.

For personal events we recommend creating a new account with a personal email address.", + "email|existing_user_added_link_changed": "Your link has been changed from {prevLinkWithoutProtocol} to {newLinkWithoutProtocol} but don't worry, all previous links still work and redirect appropriately.

Please note: All of your personal event types have been moved into the {teamName} organisation, which may also include potential personal link.

Please log in and make sure you have no private events on your new organisational account.

For personal events we recommend creating a new account with a personal email address.

Enjoy your new clean link: {newLinkWithoutProtocol}", "email_organization_created|subject": "Your organization has been created", - "your_current_plan":"Your current plan", - "organization_price_per_user_month":"$37 per user per month (30 seats minimum)", + "your_current_plan": "Your current plan", + "organization_price_per_user_month": "$37 per user per month (30 seats minimum)", "privacy_organization_description": "Manage privacy settings for your organization", "privacy": "Privacy", "team_will_be_under_org": "New teams will be under your organization", @@ -2472,10 +2472,10 @@ "review": "Review", "reviewed": "Reviewed", "unreviewed": "Unreviewed", - "rating_url_info":"The URL for Rating Feedback Form", - "no_show_url_info":"The URL for No Show Feedback", - "no_support_needed":"No Support Needed?", - "hide_support":"Hide Support", + "rating_url_info": "The URL for Rating Feedback Form", + "no_show_url_info": "The URL for No Show Feedback", + "no_support_needed": "No Support Needed?", + "hide_support": "Hide Support", "event_ratings": "Average Ratings", "event_no_show": "Host No Show", "recent_ratings": "Recent ratings", @@ -2517,6 +2517,11 @@ "event_expired": "This event is expired", "skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ", "skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.", + "disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type", + "disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.", + "disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type", + "type_confirm_to_continue": "Type confirm to continue", + "disable_email": "Disable email", "grant_admin_api": "Grant Admin API Access", "revoke_admin_api": "Revoke Admin API Access", "apple_connect_atom_label": "Connect Apple Calendar", diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index 5218ac81800b9c..89c78ddb85b456 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -9,6 +9,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; @@ -336,22 +337,26 @@ export class PaymentService implements IAbstractPaymentService { startTime: { toISOString: () => string }; uid: string; }, - paymentData: Payment + paymentData: Payment, + eventTypeMetadata?: EventTypeMetadata ): Promise { - await sendAwaitingPaymentEmail({ - ...event, - paymentInfo: { - link: createPaymentLink({ - paymentUid: paymentData.uid, - name: booking.user?.name, - email: booking.user?.email, - date: booking.startTime.toISOString(), - }), - paymentOption: paymentData.paymentOption || "ON_BOOKING", - amount: paymentData.amount, - currency: paymentData.currency, + await sendAwaitingPaymentEmail( + { + ...event, + paymentInfo: { + link: createPaymentLink({ + paymentUid: paymentData.uid, + name: booking.user?.name, + email: booking.user?.email, + date: booking.startTime.toISOString(), + }), + paymentOption: paymentData.paymentOption || "ON_BOOKING", + amount: paymentData.amount, + currency: paymentData.currency, + }, }, - }); + eventTypeMetadata + ); } async deletePayment(paymentId: Payment["id"]): Promise { diff --git a/packages/app-store/vital/lib/reschedule.ts b/packages/app-store/vital/lib/reschedule.ts index c800597dcb99e3..4d34043d057a6a 100644 --- a/packages/app-store/vital/lib/reschedule.ts +++ b/packages/app-store/vital/lib/reschedule.ts @@ -10,6 +10,7 @@ import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { Person } from "@calcom/types/Calendar"; import { getCalendar } from "../../_utils/getCalendar"; @@ -41,6 +42,11 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, + eventType: { + select: { + metadata: true, + }, + }, }, where: { uid: bookingUid, @@ -141,9 +147,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); + await sendRequestRescheduleEmail( + builder.calendarEvent, + { + rescheduleLink: builder.rescheduleLink, + }, + bookingToReschedule?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { if (error instanceof Error) { logger.error(error.message); diff --git a/packages/app-store/wipemycalother/lib/reschedule.ts b/packages/app-store/wipemycalother/lib/reschedule.ts index ff8d2afe28a327..91f3da71408c60 100644 --- a/packages/app-store/wipemycalother/lib/reschedule.ts +++ b/packages/app-store/wipemycalother/lib/reschedule.ts @@ -10,6 +10,7 @@ import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { Person } from "@calcom/types/Calendar"; import { getCalendar } from "../../_utils/getCalendar"; @@ -41,6 +42,11 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, + eventType: { + select: { + metadata: true, + }, + }, }, where: { uid: bookingUid, @@ -141,9 +147,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); + await sendRequestRescheduleEmail( + builder.calendarEvent, + { + rescheduleLink: builder.rescheduleLink, + }, + bookingToReschedule?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { if (error instanceof Error) { logger.error(error.message); diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 484464efb46fc4..525954819c9a5b 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; +import type { z } from "zod"; import type { EventNameObjectType } from "@calcom/core/event"; import { getEventName } from "@calcom/core/event"; @@ -8,6 +9,7 @@ import type BaseEmail from "@calcom/emails/templates/_base-email"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail"; @@ -61,6 +63,8 @@ import SlugReplacementEmail from "./templates/slug-replacement-email"; import type { TeamInvite } from "./templates/team-invite-email"; import TeamInviteEmail from "./templates/team-invite-email"; +type EventTypeMetadata = z.infer; + const sendEmail = (prepare: () => BaseEmail) => { return new Promise((resolve, reject) => { try { @@ -72,16 +76,25 @@ const sendEmail = (prepare: () => BaseEmail) => { }); }; +const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => { + return !!metadata?.disableStandardEmails?.all?.attendee; +}; + +const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => { + return !!metadata?.disableStandardEmails?.all?.host; +}; + export const sendScheduledEmails = async ( calEvent: CalendarEvent, eventNameObject?: EventNameObjectType, hostEmailDisabled?: boolean, - attendeeEmailDisabled?: boolean + attendeeEmailDisabled?: boolean, + eventTypeMetadata?: EventTypeMetadata ) => { const formattedCalEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - if (!hostEmailDisabled) { + if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) { emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent }))); if (formattedCalEvent.team) { @@ -93,7 +106,7 @@ export const sendScheduledEmails = async ( } } - if (!attendeeEmailDisabled) { + if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) { emailsToSend.push( ...formattedCalEvent.attendees.map((attendee) => { return sendEmail( @@ -117,7 +130,12 @@ export const sendScheduledEmails = async ( }; // for rescheduled round robin booking that assigned new members -export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinScheduledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const formattedCalEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -130,7 +148,12 @@ export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, mem await Promise.all(emailsToSend); }; -export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinRescheduledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -143,7 +166,12 @@ export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, m await Promise.all(emailsToSend); }; -export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinCancelledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -154,37 +182,50 @@ export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, mem await Promise.all(emailsToSend); }; -export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { +export const sendRescheduledEmails = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); - if (calendarEvent.team) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) - ); + if (calendarEvent.team) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee)); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee)); + }) + ); + } await Promise.all(emailsToSend); }; -export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => { +export const sendRescheduledSeatEmail = async ( + calEvent: CalendarEvent, + attendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const clonedCalEvent = cloneDeep(calendarEvent); - const emailsToSend: Promise[] = [ - sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)), - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })), - ]; + const emailsToSend: Promise[] = []; + + if (!eventTypeDisableHostEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee))); await Promise.all(emailsToSend); }; @@ -195,13 +236,14 @@ export const sendScheduledSeatsEmails = async ( newSeat: boolean, showAttendees: boolean, hostEmailDisabled?: boolean, - attendeeEmailDisabled?: boolean + attendeeEmailDisabled?: boolean, + eventTypeMetadata?: EventTypeMetadata ) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - if (!hostEmailDisabled) { + if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) { emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat }))); if (calendarEvent.team) { @@ -213,7 +255,7 @@ export const sendScheduledSeatsEmails = async ( } } - if (!attendeeEmailDisabled) { + if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) { emailsToSend.push( sendEmail( () => @@ -231,16 +273,30 @@ export const sendScheduledSeatsEmails = async ( await Promise.all(emailsToSend); }; -export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => { +export const sendCancelledSeatEmails = async ( + calEvent: CalendarEvent, + cancelledAttendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { const formattedCalEvent = formatCalEvent(calEvent); const clonedCalEvent = cloneDeep(formattedCalEvent); - await Promise.all([ - sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)), - sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })), - ]); + const emailsToSend: Promise[] = []; + + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) + emailsToSend.push( + sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })) + ); + + await Promise.all(emailsToSend); }; -export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { +export const sendOrganizerRequestEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -256,12 +312,18 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { await Promise.all(emailsToSend); }; -export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => { +export const sendAttendeeRequestEmail = async ( + calEvent: CalendarEvent, + attendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee)); }; -export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { +export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -276,57 +338,67 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { export const sendCancelledEmails = async ( calEvent: CalendarEvent, - eventNameObject: Pick + eventNameObject: Pick, + eventTypeMetadata?: EventTypeMetadata ) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - - emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent }))); const calEventLength = calendarEvent.length; + const eventDuration = calEventLength as number; + if (typeof calEventLength !== "number") { logger.error( "`calEventLength` is not a number", safeStringify({ calEventLength, calEventTitle: calEvent.title, bookingId: calEvent.bookingId }) ); } - const eventDuration = calEventLength as number; - if (calendarEvent.team?.members) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })) - ); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent }))); + + if (calendarEvent.team?.members) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail( - () => - new AttendeeCancelledEmail( - { - ...calendarEvent, - title: getEventName({ - ...eventNameObject, - t: attendee.language.translate, - attendeeName: attendee.name, - host: calendarEvent.organizer.name, - eventType: calendarEvent.type, - eventDuration, - ...(calendarEvent.responses && { bookingFields: calendarEvent.responses }), - ...(calendarEvent.location && { location: calendarEvent.location }), - }), - }, - attendee - ) - ); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail( + () => + new AttendeeCancelledEmail( + { + ...calendarEvent, + title: getEventName({ + ...eventNameObject, + t: attendee.language.translate, + attendeeName: attendee.name, + host: calendarEvent.organizer.name, + eventType: calendarEvent.type, + eventDuration, + ...(calendarEvent.responses && { bookingFields: calendarEvent.responses }), + ...(calendarEvent.location && { location: calendarEvent.location }), + }), + }, + attendee + ) + ); + }) + ); + } await Promise.all(emailsToSend); }; -export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => { +export const sendOrganizerRequestReminderEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -342,7 +414,11 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) } }; -export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => { +export const sendAwaitingPaymentEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const emailsToSend: Promise[] = []; emailsToSend.push( @@ -398,38 +474,49 @@ export const sendChangeOfEmailVerificationLink = async (verificationInput: Chang export const sendRequestRescheduleEmail = async ( calEvent: CalendarEvent, - metadata: { rescheduleLink: string } + metadata: { rescheduleLink: string }, + eventTypeMetadata?: EventTypeMetadata ) => { const emailsToSend: Promise[] = []; const calendarEvent = formatCalEvent(calEvent); - emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata))); - - emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata))); + } + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata))); + } await Promise.all(emailsToSend); }; -export const sendLocationChangeEmails = async (calEvent: CalendarEvent) => { +export const sendLocationChangeEmails = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent }))); - if (calendarEvent.team?.members) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember })) - ); + if (calendarEvent.team?.members) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee)); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee)); + }) + ); + } await Promise.all(emailsToSend); }; @@ -476,7 +563,12 @@ export const sendSlugReplacementEmail = async ({ await sendEmail(() => new SlugReplacementEmail(email, name, teamName, slug, t)); }; -export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => { +export const sendNoShowFeeChargedEmail = async ( + attendee: Person, + evt: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee)); }; diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index c6869c64fdbde6..9252315690665a 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -1,3 +1,5 @@ +import type { Prisma } from "@prisma/client"; + import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails"; import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; @@ -6,6 +8,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] }); @@ -28,6 +31,7 @@ export async function handleBookingRequested(args: { requiresConfirmation: boolean; title: string; teamId?: number | null; + metadata: Prisma.JsonValue; } | null; eventTypeId: number | null; userId: number | null; @@ -37,8 +41,12 @@ export async function handleBookingRequested(args: { const { evt, booking } = args; log.debug("Emails: Sending booking requested emails"); - await sendOrganizerRequestEmail({ ...evt }); - await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); + await sendOrganizerRequestEmail({ ...evt }, booking?.eventType?.metadata as EventTypeMetadata); + await sendAttendeeRequestEmail( + { ...evt }, + evt.attendees[0], + booking?.eventType?.metadata as EventTypeMetadata + ); const orgId = await getOrgIdFromMemberOrTeamId({ memberId: booking.userId, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 48d84cf2f77b5b..6a20fb5201ba85 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -26,6 +26,7 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { deleteAllWorkflowReminders, getAllWorkflowsFromEventType, @@ -320,7 +321,11 @@ async function handler(req: CustomRequest) { const dataForWebhooks = { evt, webhooks, eventTypeInfo }; // If it's just an attendee of a booking then just remove them from that booking - const result = await cancelAttendeeSeat(req, dataForWebhooks); + const result = await cancelAttendeeSeat( + req, + dataForWebhooks, + bookingToDelete?.eventType?.metadata as EventTypeMetadata + ); if (result) return { success: true, @@ -503,7 +508,11 @@ async function handler(req: CustomRequest) { try { // TODO: if emails fail try to requeue them if (!platformClientId || (platformClientId && arePlatformEmailsEnabled)) - await sendCancelledEmails(evt, { eventName: bookingToDelete?.eventType?.eventName }); + await sendCancelledEmails( + evt, + { eventName: bookingToDelete?.eventType?.eventName }, + bookingToDelete?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { console.error("Error deleting event", error); } diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 0f964dd50fd91c..3bc204e3865d1b 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -111,7 +111,8 @@ export async function handleConfirmation(args: { { ...evt, additionalInformation: metadata }, undefined, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventTypeMetadata ); } catch (error) { log.error(error); @@ -255,13 +256,15 @@ export async function handleConfirmation(args: { evtOfBooking.uid = updatedBookings[index].uid; const isFirstBooking = index === 0; - await scheduleMandatoryReminder( - evtOfBooking, - workflows, - false, - !!updatedBookings[index].eventType?.owner?.hideBranding, - evt.attendeeSeatId - ); + if (!eventTypeMetadata?.disableStandardEmails?.all?.attendee) { + await scheduleMandatoryReminder( + evtOfBooking, + workflows, + false, + !!updatedBookings[index].eventType?.owner?.hideBranding, + evt.attendeeSeatId + ); + } await scheduleWorkflowReminders({ workflows, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index adb8fa91d5bf44..ccf4a7912bf357 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1357,17 +1357,20 @@ async function handler( originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) ); - sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers); - sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers); - sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers); + sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers, eventType.metadata); + sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers, eventType.metadata); + sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); } else { // send normal rescheduled emails (non round robin event, where organizers stay the same) - await sendRescheduledEmails({ - ...copyEvent, - additionalInformation: metadata, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalInformation: metadata, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType?.metadata + ); } } // If it's not a reschedule, doesn't require confirmation and there's no price, @@ -1504,7 +1507,8 @@ async function handler( }, eventNameObject, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventType.metadata ); } } @@ -1533,8 +1537,8 @@ async function handler( calEvent: getPiiFreeCalendarEvent(evt), }) ); - await sendOrganizerRequestEmail({ ...evt, additionalNotes }); - await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); + await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); + await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); } if (booking.location?.startsWith("http")) { @@ -1745,13 +1749,15 @@ async function handler( const evtWithMetadata = { ...evt, metadata, eventType: { slug: eventType.slug } }; - await scheduleMandatoryReminder( - evtWithMetadata, - workflows, - !isConfirmedByDefault, - !!eventType.owner?.hideBranding, - evt.attendeeSeatId - ); + if (!eventType.metadata?.disableStandardEmails?.all?.attendee) { + await scheduleMandatoryReminder( + evtWithMetadata, + workflows, + !isConfirmedByDefault, + !!eventType.owner?.hideBranding, + evt.attendeeSeatId + ); + } try { await scheduleWorkflowReminders({ diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index af732a9027f751..06624de91d5c00 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -11,6 +11,7 @@ import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { deleteAllWorkflowReminders } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -28,7 +29,8 @@ async function cancelAttendeeSeat( }[]; evt: CalendarEvent; eventTypeInfo: EventTypeInfo; - } + }, + eventTypeMetadata: EventTypeMetadata ) { const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body); const { webhooks, evt, eventTypeInfo } = dataForWebhooks; @@ -105,10 +107,14 @@ async function cancelAttendeeSeat( const tAttendees = await getTranslation(attendee.locale ?? "en", "common"); - await sendCancelledSeatEmails(evt, { - ...attendee, - language: { translate: tAttendees, locale: attendee.locale ?? "en" }, - }); + await sendCancelledSeatEmails( + evt, + { + ...attendee, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }, + eventTypeMetadata + ); } evt.attendees = attendee diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 9a6fd8b17e01a8..6bedcc2f2ee277 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -138,7 +138,8 @@ const createNewSeat = async ( newSeat, !!eventType.seatsShowAttendees, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventType.metadata ); } const credentials = await refreshCredentials(allCredentials); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index e340319666ff76..c3a8369f79e97d 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -18,7 +18,7 @@ const attendeeRescheduleSeatedBooking = async ( originalBookingEvt: CalendarEvent, eventManager: EventManager ) => { - const { tAttendees, bookingSeat, bookerEmail, evt } = rescheduleSeatedBookingObject; + const { tAttendees, bookingSeat, bookerEmail, evt, eventType } = rescheduleSeatedBookingObject; let { originalRescheduledBooking } = rescheduleSeatedBookingObject; seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" }; @@ -91,7 +91,7 @@ const attendeeRescheduleSeatedBooking = async ( await eventManager.updateCalendarAttendees(copyEvent, newTimeSlotBooking); - await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person); + await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person, eventType.metadata); const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { return attendee.email !== bookerEmail; }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index 7efe855e038b2b..8b009e7ee0c223 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -136,11 +136,14 @@ const combineTwoSeatedBookings = async ( if (noEmail !== true && isConfirmedByDefault) { // TODO send reschedule emails to attendees of the old booking loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails({ - ...copyEvent, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType.metadata + ); } // Update the old booking with the cancelled status diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 93dc6f4342660d..42222aa9296602 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -90,11 +90,14 @@ const moveSeatedBookingToNewTimeSlot = async ( if (noEmail !== true && isConfirmedByDefault) { const copyEvent = cloneDeep(evt); loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails({ - ...copyEvent, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType.metadata + ); } const foundBooking = await findBookingQuery(newBooking.id); diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index bf69811dfe1085..c5343fdf7cfad8 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -13,7 +13,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { bookingMinimalSelect, prisma } from "@calcom/prisma"; import { AppCategories, BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeAppMetadataSchema, EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -244,6 +244,7 @@ const handleDeleteCredential = async ({ seatsPerTimeSlot: true, seatsShowAttendees: true, eventName: true, + metadata: true, }, }, uid: true, @@ -335,7 +336,8 @@ const handleDeleteCredential = async ({ }, { eventName: booking?.eventType?.eventName, - } + }, + booking?.eventType?.metadata as EventTypeMetadata ); } }); diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index fa07ee1dc8f591..3992966b8d7963 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -107,8 +107,8 @@ const handleSetupSuccess = async (event: Stripe.Event) => { paid: true, }); } else { - await sendOrganizerRequestEmail({ ...evt }); - await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); + await sendOrganizerRequestEmail({ ...evt }, eventType.metadata); + await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0], eventType.metadata); } }; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index c566d3e641bc55..6f5c7ee67fcabf 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -26,6 +26,7 @@ import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; const bookingSelect = { @@ -398,15 +399,19 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }); } - await sendRoundRobinCancelledEmails(cancelledRRHostEvt, [ - { - ...previousRRHost, - name: previousRRHost.name || "", - username: previousRRHost.username || "", - timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), - language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, - }, - ]); + await sendRoundRobinCancelledEmails( + cancelledRRHostEvt, + [ + { + ...previousRRHost, + name: previousRRHost.name || "", + username: previousRRHost.username || "", + timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), + language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, + }, + ], + eventType?.metadata as EventTypeMetadata + ); } // Handle changing workflows with organizer diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts index 88ae20af71ef47..2288dff7aa6d23 100644 --- a/packages/lib/payment/handlePayment.ts +++ b/packages/lib/payment/handlePayment.ts @@ -75,7 +75,7 @@ const handlePayment = async ( throw new Error("Payment data is null"); } try { - await paymentInstance.afterPayment(evt, booking, paymentData); + await paymentInstance.afterPayment(evt, booking, paymentData, selectedEventType?.metadata); } catch (e) { console.error(e); } diff --git a/packages/lib/payment/handlePaymentSuccess.ts b/packages/lib/payment/handlePaymentSuccess.ts index 1baa03ac000166..1e5eaa8fcffdd5 100644 --- a/packages/lib/payment/handlePaymentSuccess.ts +++ b/packages/lib/payment/handlePaymentSuccess.ts @@ -76,7 +76,7 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number) log.debug(`handling booking request for eventId ${eventType.id}`); } } else { - await sendScheduledEmails({ ...evt }); + await sendScheduledEmails({ ...evt }, undefined, undefined, undefined, eventType.metadata); } throw new HttpCode({ diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 80b594e412f93f..71c2d51c47bbde 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -80,6 +80,12 @@ export const EventTypeMetaDataSchema = z disableSuccessPage: z.boolean().optional(), disableStandardEmails: z .object({ + all: z + .object({ + host: z.boolean().optional(), + attendee: z.boolean().optional(), + }) + .optional(), confirmation: z .object({ host: z.boolean().optional(), @@ -108,6 +114,8 @@ export const EventTypeMetaDataSchema = z }) .nullable(); +export type EventTypeMetadata = z.infer; + export const eventTypeBookingFields = formBuilderFieldsSchema; export const BookingFieldTypeEnum = eventTypeBookingFields.element.shape.type.Enum; export type BookingFieldType = FormBuilderFieldType; diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts index 41b94ecca2ac65..99dea2cc9628ec 100644 --- a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -5,7 +5,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; -import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { bookingMetadataSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -204,13 +204,16 @@ export const Handler = async ({ ctx, input }: Options) => { videoCallData, }; + const eventTypeMetadata = EventTypeMetaDataSchema.parse(updatedBooking?.eventType?.metadata); + await sendScheduledEmails( { ...evt, }, undefined, false, - false + false, + eventTypeMetadata ); return { isBookingAlreadyAcceptedBySomeoneElse, meetingUrl: locationVideoCallUrl }; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 44e43a9278315b..dc33184a12ca4a 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -18,6 +18,7 @@ import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { BookingStatus, MembershipRole, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; @@ -370,7 +371,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { }); } - await sendDeclinedEmails(evt); + await sendDeclinedEmails(evt, booking.eventType?.metadata as EventTypeMetadata); const teamId = await getTeamIdFromEventType({ eventType: { diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index 636f49a35f42ce..f8fcd4bd540ad8 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -7,6 +7,7 @@ import { getTranslation } from "@calcom/lib/server"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; import { prisma } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; @@ -131,7 +132,10 @@ export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) = metadata.entryPoints = results[0].updatedEvent?.entryPoints; } try { - await sendLocationChangeEmails({ ...evt, additionalInformation: metadata }); + await sendLocationChangeEmails( + { ...evt, additionalInformation: metadata }, + booking?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { console.log("Error sending LocationChangeEmails"); } diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 54ce65d75c344b..392a34215f5606 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -22,6 +22,7 @@ import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; import { prisma } from "@calcom/prisma"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -240,9 +241,13 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule log.debug("builder.calendarEvent", safeStringify(builder.calendarEvent)); // Send emails - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); + await sendRequestRescheduleEmail( + builder.calendarEvent, + { + rescheduleLink: builder.rescheduleLink, + }, + eventType?.metadata as EventTypeMetadata + ); const evt: CalendarEvent = { title: bookingToReschedule?.title, diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index d4a467dff0b298..7f8f8eee6bdb89 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -3,12 +3,16 @@ import type { NextApiResponse, GetServerSidePropsContext } from "next"; import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated"; import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { validateIntervalLimitOrder } from "@calcom/lib"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { PrismaClient } from "@calcom/prisma"; -import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/client"; +import { WorkflowTriggerEvents } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -255,9 +259,15 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; } - if (input.metadata?.disableStandardEmails) { - //check if user is allowed to disabled standard emails + if (input.metadata?.disableStandardEmails?.all) { + if (!eventType?.team?.parentId) { + input.metadata.disableStandardEmails.all.host = false; + input.metadata.disableStandardEmails.all.attendee = false; + } + } + if (input.metadata?.disableStandardEmails?.confirmation) { + //check if user is allowed to disabled standard emails const workflows = await ctx.prisma.workflow.findMany({ where: { activeOn: { @@ -273,21 +283,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); if (input.metadata?.disableStandardEmails.confirmation?.host) { - if ( - !workflows.find( - (workflow) => !!workflow.steps.find((step) => step.action === WorkflowActions.EMAIL_HOST) - ) - ) { + if (!allowDisablingHostConfirmationEmails(workflows)) { input.metadata.disableStandardEmails.confirmation.host = false; } } if (input.metadata?.disableStandardEmails.confirmation?.attendee) { - if ( - !workflows.find( - (workflow) => !!workflow.steps.find((step) => step.action === WorkflowActions.EMAIL_ATTENDEE) - ) - ) { + if (!allowDisablingAttendeeConfirmationEmails(workflows)) { input.metadata.disableStandardEmails.confirmation.attendee = false; } } diff --git a/packages/trpc/server/routers/viewer/payments.tsx b/packages/trpc/server/routers/viewer/payments.tsx index 54c77e432e84a8..cd39e0a05610f7 100644 --- a/packages/trpc/server/routers/viewer/payments.tsx +++ b/packages/trpc/server/routers/viewer/payments.tsx @@ -8,6 +8,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTranslation } from "@calcom/lib/server/i18n"; import sendPayload from "@calcom/lib/server/webhooks/sendPayload"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { PaymentApp } from "@calcom/types/PaymentService"; @@ -149,7 +150,11 @@ export const paymentsRouter = router({ }) ); - await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt); + await sendNoShowFeeChargedEmail( + attendeesListPromises[0], + evt, + booking?.eventType?.metadata as EventTypeMetadata + ); return paymentData; } catch (err) { diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts index bdc9cd3b633a6a..ab4a0dd5362819 100644 --- a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts @@ -3,6 +3,7 @@ import dayjs from "@calcom/dayjs"; import { sendNoShowFeeChargedEmail } from "@calcom/emails"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaClient } from "@calcom/prisma"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; @@ -112,7 +113,11 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` }); } - await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt); + await sendNoShowFeeChargedEmail( + attendeesListPromises[0], + evt, + booking?.eventType?.metadata as EventTypeMetadata + ); return paymentData; } catch (err) { diff --git a/packages/types/PaymentService.d.ts b/packages/types/PaymentService.d.ts index e9ab38905edcb2..da9d1869cb9e5c 100644 --- a/packages/types/PaymentService.d.ts +++ b/packages/types/PaymentService.d.ts @@ -49,7 +49,8 @@ export interface IAbstractPaymentService { startTime: { toISOString: () => string }; uid: string; }, - paymentData: Payment + paymentData: Payment, + eventTypeMetadata?: EventTypeMetadata ): Promise; deletePayment(paymentId: Payment["id"]): Promise; isSetupAlready(): boolean; From 528a4fbb97fb8ca6d0fb7983c0778e8b779bec85 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:40:40 +0530 Subject: [PATCH 03/12] feat: Assign colors to events (#15298) * feat: Assign colors to events * update * final update * update * fix requested changes error * Update EventAdvancedTab.tsx * add contrast check * update * fix contrsterror message visibility * fix type error * fix * update test --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 152 ++++++++++-------- .../components/eventtype/EventAdvancedTab.tsx | 92 +++++++++++ .../views/event-types-listing-view.tsx | 79 +++++---- .../views/event-types-single-view.tsx | 7 + .../pages/settings/my-account/appearance.tsx | 12 +- apps/web/public/static/locales/en/common.json | 6 + .../test/lib/handleChildrenEventTypes.test.ts | 3 + .../ee/components/BrandColorsForm.tsx | 12 +- .../lib/handleChildrenEventTypes.ts | 1 + packages/features/eventtypes/lib/types.ts | 2 + packages/lib/event-types/getEventTypeById.ts | 3 +- packages/lib/index.ts | 1 + packages/lib/isEventTypeColor.ts | 15 ++ packages/lib/server/eventTypeSelect.ts | 1 + packages/lib/server/queries/teams/index.ts | 1 + packages/lib/server/repository/eventType.ts | 1 + packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + packages/prisma/zod-utils.ts | 8 + .../routers/viewer/bookings/get.handler.ts | 4 +- .../viewer/eventTypes/duplicate.handler.ts | 2 + .../viewer/eventTypes/update.handler.ts | 2 + 23 files changed, 289 insertions(+), 120 deletions(-) create mode 100644 packages/lib/isEventTypeColor.ts create mode 100644 packages/prisma/migrations/20240810164200_event_type_color/migration.sql diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 86933ef541bd97..cfcc5d9abbc70d 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -18,6 +18,7 @@ import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useGetTheme } from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -100,6 +101,12 @@ function BookingListItem(booking: BookingItemProps) { const location = booking.location as ReturnType; const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl; + const { resolvedTheme, forcedTheme } = useGetTheme(); + const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; + const eventTypeColor = + booking.eventType.eventTypeColor && + booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"]; + const locationToDisplay = getSuccessPageLocationMessage( locationVideoCallUrl ? locationVideoCallUrl : location, t, @@ -355,79 +362,82 @@ function BookingListItem(booking: BookingItemProps) { /> )} - - -
-
{startTime}
-
- {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} - {formatTime(booking.endTime, userTimeFormat, userTimeZone)} - -
- {!isPending && ( - - )} - {isPending && ( - - {t("unconfirmed")} - - )} - {booking.eventType?.team && ( - - {booking.eventType.team.name} - - )} - {booking.paid && !booking.payment[0] ? ( - - {t("error_collecting_card")} - - ) : booking.paid ? ( - - {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} - - ) : null} - {recurringDates !== undefined && ( -
- +
+ {eventTypeColor &&
} + +
+
{startTime}
+
+ {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} + {formatTime(booking.endTime, userTimeFormat, userTimeZone)} +
- )} -
- + {!isPending && ( + + )} + {isPending && ( + + {t("unconfirmed")} + + )} + {booking.eventType?.team && ( + + {booking.eventType.team.name} + + )} + {booking.paid && !booking.payment[0] ? ( + + {t("error_collecting_card")} + + ) : booking.paid ? ( + + {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} + + ) : null} + {recurringDates !== undefined && ( +
+ +
+ )} +
+ +
diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 04c4095d035e68..d6eaa871b3b374 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -19,8 +19,10 @@ import type { EditableSchema } from "@calcom/features/form-builder/schema"; import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; import { classNames } from "@calcom/lib"; import cx from "@calcom/lib/classNames"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; +import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -38,6 +40,7 @@ import { TextField, Tooltip, showToast, + ColorPicker, } from "@calcom/ui"; import RequiresConfirmationController from "./RequiresConfirmationController"; @@ -51,6 +54,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick(); const { t } = useLocale(); const [showEventNameTip, setShowEventNameTip] = useState(false); + const [darkModeError, setDarkModeError] = useState(false); + const [lightModeError, setLightModeError] = useState(false); const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink")); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl")); const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState( @@ -131,9 +136,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick setShowEventNameTip(false); + + const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor); + + const [eventTypeColorState, setEventTypeColorState] = useState( + eventType.eventTypeColor || { + lightEventTypeColor: DEFAULT_LIGHT_BRAND_COLOR, + darkEventTypeColor: DEFAULT_DARK_BRAND_COLOR, + } + ); + const displayDestinationCalendarSelector = !!connectedCalendarsQuery.data?.connectedCalendars.length && (!team || isChildrenManagedEventType); @@ -537,6 +553,82 @@ export const EventAdvancedTab = ({ eventType, team }: Pick )} /> + ( + { + const value = e ? eventTypeColorState : null; + formMethods.setValue("eventTypeColor", value, { + shouldDirty: true, + }); + setIsEventTypeColorChecked(e); + }} + childrenClassName="lg:ml-0"> +
+
+

{t("light_event_type_color")}

+ { + if (checkWCAGContrastColor("#ffffff", value)) { + const newVal = { + ...eventTypeColorState, + lightEventTypeColor: value, + }; + setLightModeError(false); + formMethods.setValue("eventTypeColor", newVal, { shouldDirty: true }); + setEventTypeColorState(newVal); + } else { + setLightModeError(true); + } + }} + /> + {lightModeError ? ( +
+ +
+ ) : null} +
+ +
+

{t("dark_event_type_color")}

+ { + if (checkWCAGContrastColor("#101010", value)) { + const newVal = { + ...eventTypeColorState, + darkEventTypeColor: value, + }; + setDarkModeError(false); + formMethods.setValue("eventTypeColor", newVal, { shouldDirty: true }); + setEventTypeColorState(newVal); + } else { + setDarkModeError(true); + } + }} + /> + {darkModeError ? ( +
+ +
+ ) : null} +
+
+
+ )} + /> {isRoundRobinEventType && ( { const { t } = useLocale(); + const { resolvedTheme, forcedTheme } = useGetTheme(); + const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; + const parsedeventTypeColor = parseEventTypeColor(type.eventTypeColor); + const eventTypeColor = + parsedeventTypeColor && parsedeventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"]; const content = () => (
@@ -238,40 +245,46 @@ const Item = ({
); - return readOnly ? ( -
- {content()} - -
- ) : ( - -
- - {type.title} - - {group.profile.slug ? ( - - {`/${group.profile.slug}/${type.slug}`} - - ) : null} - {readOnly && ( - - {t("readonly")} - + return ( +
+ {eventTypeColor && ( +
+ )} +
+ {readOnly ? ( +
+ {content()} + +
+ ) : ( + +
+ + {type.title} + + {group.profile.slug ? ( + + {`/${group.profile.slug}/${type.slug}`} + + ) : null} + {readOnly && ( + + {t("readonly")} + + )} +
+ + )}
- - +
); }; @@ -470,7 +483,7 @@ export const EventTypeList = ({ return (
  • -
    +
    {!(firstItem && firstItem.id === type.id) && ( moveEventType(index, -1)} arrowDirection="up" /> )} diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index e53fd01e230bcd..ee64f07cb6f985 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -282,6 +282,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf length: eventType.length, hidden: eventType.hidden, hashedLink: eventType.hashedLink?.link || undefined, + eventTypeColor: eventType.eventTypeColor || null, periodDates: { startDate: periodDates.startDate, endDate: periodDates.endDate, @@ -518,6 +519,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf const updatedFields: Partial = {}; Object.keys(dirtyFields).forEach((key) => { const typedKey = key as keyof typeof dirtyFields; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore updatedFields[typedKey] = undefined; const isDirty = isFieldDirty(typedKey); if (isDirty) { @@ -545,6 +548,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf onlyShowFirstAvailableSlot, durationLimits, recurringEvent, + eventTypeColor, locations, metadata, customInputs, @@ -615,6 +619,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf bookingLimits, onlyShowFirstAvailableSlot, durationLimits, + eventTypeColor, seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount, @@ -703,6 +708,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf onlyShowFirstAvailableSlot, durationLimits, recurringEvent, + eventTypeColor, locations, metadata, customInputs, @@ -765,6 +771,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf bookingLimits, onlyShowFirstAvailableSlot, durationLimits, + eventTypeColor, seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount, diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index a2f0bb36b9f22f..9fe645435bd68b 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -348,12 +348,11 @@ const AppearanceView = ({ defaultValue={DEFAULT_BRAND_COLOURS.light} resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#ffffff", value); + if (checkWCAGContrastColor("#ffffff", value)) { setLightModeError(false); brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); - } catch (err) { - setLightModeError(false); + } else { + setLightModeError(true); } }} /> @@ -377,11 +376,10 @@ const AppearanceView = ({ defaultValue={DEFAULT_BRAND_COLOURS.dark} resetDefaultValue={DEFAULT_DARK_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#101010", value); + if (checkWCAGContrastColor("#101010", value)) { setDarkModeError(false); brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - } catch (err) { + } else { setDarkModeError(true); } }} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6c3a579e35e8f8..1972e02d06fdc3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -70,6 +70,8 @@ "meeting_awaiting_payment": "Your meeting is awaiting payment", "dark_theme_contrast_error": "Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", "light_theme_contrast_error": "Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", + "event_type_color_light_theme_contrast_error": "Light Theme color doesn't pass contrast check. We recommend you change this color so your event types color will be more visible.", + "event_type_color_dark_theme_contrast_error": "Dark Theme color doesn't pass contrast check. We recommend you change this color so your event types color will be more visible.", "payment_not_created_error": "Payment could not be created", "couldnt_charge_card_error": "Could not charge card for Payment", "no_available_users_found_error": "No available users found. Could you try another time slot?", @@ -776,6 +778,8 @@ "brand_color": "Brand Color", "light_brand_color": "Brand Color (Light Theme)", "dark_brand_color": "Brand Color (Dark Theme)", + "light_event_type_color": "Event Type Color (Light Theme)", + "dark_event_type_color": "Event Type Color (Dark Theme)", "file_not_named": "File is not named [idOrSlug]/[user]", "create_team": "Create Team", "name": "Name", @@ -2496,6 +2500,8 @@ "unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later", "updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later", "creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later", + "event_type_color": "Event type color", + "event_type_color_description": "This is only used for event type & booking differentiation within the app. It is not displayed to bookers.", "mark_as_no_show_title": "Mark as no show", "x_marked_as_no_show": "{{x}} marked as no-show", "x_unmarked_as_no_show": "{{x}} unmarked as no-show", diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 6f84b0928ab638..afc0abe74fbc21 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -146,6 +146,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, userId: 4, }, }); @@ -301,6 +302,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, hashedLink: undefined, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, @@ -417,6 +419,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, hashedLink: undefined, locations: [], lockTimeZoneToggleOnBookingPage: false, diff --git a/packages/features/ee/components/BrandColorsForm.tsx b/packages/features/ee/components/BrandColorsForm.tsx index df924b19200f80..4651e6faef4949 100644 --- a/packages/features/ee/components/BrandColorsForm.tsx +++ b/packages/features/ee/components/BrandColorsForm.tsx @@ -66,12 +66,11 @@ const BrandColorsForm = ({ defaultValue={brandColor || DEFAULT_LIGHT_BRAND_COLOR} resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#ffffff", value); + if (checkWCAGContrastColor("#ffffff", value)) { setLightModeError(false); brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); - } catch (err) { - setLightModeError(false); + } else { + setLightModeError(true); } }} /> @@ -95,11 +94,10 @@ const BrandColorsForm = ({ defaultValue={darkBrandColor || DEFAULT_DARK_BRAND_COLOR} resetDefaultValue={DEFAULT_DARK_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#101010", value); + if (checkWCAGContrastColor("#101010", value)) { setDarkModeError(false); brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - } catch (err) { + } else { setDarkModeError(true); } }} diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index ed5d19b1631d3d..29ca66d784caca 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -185,6 +185,7 @@ export default async function handleChildrenEventTypes({ metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined, bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, + eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, userId, users: { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 6188b509c08f6a..4aea36d1de8898 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -6,6 +6,7 @@ import type { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { customInputSchema } from "@calcom/prisma/zod-utils"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { eventTypeColor } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar"; @@ -47,6 +48,7 @@ export type FormValues = { hidden: boolean; hideCalendarNotes: boolean; hashedLink: string | undefined; + eventTypeColor: z.infer; locations: { type: EventLocationType["type"]; address?: string; diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 67ff434a280e1b..af60ac6eb1716e 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -4,7 +4,7 @@ import { getLocationGroupedOptions } from "@calcom/app-store/server"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import type { LocationObject } from "@calcom/core/location"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; -import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib"; +import { parseBookingLimit, parseDurationLimit, parseRecurringEvent, parseEventTypeColor } from "@calcom/lib"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getTranslation } from "@calcom/lib/server/i18n"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; @@ -108,6 +108,7 @@ export const getEventTypeById = async ({ recurringEvent: parseRecurringEvent(restEventType.recurringEvent), bookingLimits: parseBookingLimit(restEventType.bookingLimits), durationLimits: parseDurationLimit(restEventType.durationLimits), + eventTypeColor: parseEventTypeColor(restEventType.eventTypeColor), locations: locations as unknown as LocationObject[], metadata: parsedMetaData, customInputs: parsedCustomInputs, diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 5d28b67b544126..96f77226b6d1ab 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -3,6 +3,7 @@ export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj"; export * from "./isRecurringEvent"; export * from "./isBookingLimits"; export * from "./isDurationLimits"; +export * from "./isEventTypeColor"; export * from "./validateIntervalLimitOrder"; export * from "./schedules"; export * from "./event-types"; diff --git a/packages/lib/isEventTypeColor.ts b/packages/lib/isEventTypeColor.ts new file mode 100644 index 00000000000000..77b7e1cb85c63d --- /dev/null +++ b/packages/lib/isEventTypeColor.ts @@ -0,0 +1,15 @@ +import type { z } from "zod"; + +import { eventTypeColor as eventTypeColorSchema } from "@calcom/prisma/zod-utils"; + +type EventTypeColor = z.infer; +export function isEventTypeColor(obj: unknown): obj is EventTypeColor { + return eventTypeColorSchema.safeParse(obj).success; +} + +export function parseEventTypeColor(obj: unknown): EventTypeColor { + let eventTypeColor: EventTypeColor = null; + if (isEventTypeColor(obj)) eventTypeColor = obj; + + return eventTypeColor; +} diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index f2d539f0a853b5..29d68c42ab9b69 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -51,4 +51,5 @@ export const eventTypeSelect = Prisma.validator()({ secondaryEmailId: true, bookingLimits: true, durationLimits: true, + eventTypeColor: true, }); diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 1c1b1e34812316..647c4fa2308ae7 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -346,6 +346,7 @@ export async function updateNewTeamMemberEventTypes(userId: number, teamId: numb metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined, bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, + eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, userId, users: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index f6c27e6f1e0a0c..e1a6110c6c9655 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -452,6 +452,7 @@ export class EventTypeRepository { afterEventBuffer: true, slotInterval: true, hashedLink: true, + eventTypeColor: true, bookingLimits: true, onlyShowFirstAvailableSlot: true, durationLimits: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index d75f3524294a61..7e33ea50614662 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -126,6 +126,7 @@ export const buildEventType = (eventType?: Partial): EventType => { parentId: null, profileId: null, secondaryEmailId: null, + eventTypeColor: null, ...eventType, }; }; diff --git a/packages/prisma/migrations/20240810164200_event_type_color/migration.sql b/packages/prisma/migrations/20240810164200_event_type_color/migration.sql new file mode 100644 index 00000000000000..caa0ac1e782c3f --- /dev/null +++ b/packages/prisma/migrations/20240810164200_event_type_color/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "eventTypeColor" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 85f420791ca816..cb005a2d39c595 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -137,6 +137,8 @@ model EventType { assignAllTeamMembers Boolean @default(false) useEventTypeDestinationCalendarEmail Boolean @default(false) aiPhoneCallConfig AIPhoneCallConfiguration? + /// @zod.custom(imports.eventTypeColor) + eventTypeColor Json? rescheduleWithSameRoundRobinHost Boolean @default(false) secondaryEmailId Int? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 71c2d51c47bbde..eca0100aab6bb3 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -187,6 +187,13 @@ export const iso8601 = z.string().transform((val, ctx) => { return d; }); +export const eventTypeColor = z + .object({ + lightEventTypeColor: z.string(), + darkEventTypeColor: z.string(), + }) + .nullable(); + export const intervalLimitsType = z .object({ PER_DAY: z.number().optional(), @@ -663,6 +670,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit { recurringEvent, bookingLimits, durationLimits, + eventTypeColor, metadata, workflows, hashedLink, @@ -112,6 +113,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { recurringEvent: recurringEvent || undefined, bookingLimits: bookingLimits ?? undefined, durationLimits: durationLimits ?? undefined, + eventTypeColor: eventTypeColor ?? undefined, metadata: metadata === null ? Prisma.DbNull : metadata, bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 7f8f8eee6bdb89..d9ae73b3c5e597 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -53,6 +53,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { destinationCalendar, customInputs, recurringEvent, + eventTypeColor, users, children, assignAllTeamMembers, @@ -137,6 +138,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { ...rest, bookingFields, metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), + eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject), }; data.locations = locations ?? undefined; if (periodType) { From 3f2d83d89fcb2cfa931f2911ef5b17fc0295d8c4 Mon Sep 17 00:00:00 2001 From: Kartik Saini <41051387+kart1ka@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:41:22 +0530 Subject: [PATCH 04/12] fix: doesn't fall back to calvideo if wrong destination is used for google meet (#16195) --- packages/core/EventManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index e5fc5a5aa5a4d1..1803c58b092e8a 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -170,6 +170,7 @@ export default class EventManager { if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") { log.warn("Falling back to Cal Video integration as Google Calendar not installed"); evt["location"] = "integrations:daily"; + evt["conferenceCredentialId"] = undefined; } const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; From 1adf2ad5a84b0c2ba85913e52e03734e621c3825 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 14 Aug 2024 15:33:17 +0200 Subject: [PATCH 05/12] fix: transcribe UI subtitle were blocking chat (#16097) Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- apps/web/modules/videos/ai/ai-transcribe.tsx | 3 ++- apps/web/modules/videos/views/videos-single-view.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/modules/videos/ai/ai-transcribe.tsx b/apps/web/modules/videos/ai/ai-transcribe.tsx index 5e7bf9152ca2d8..380f7d3c545592 100644 --- a/apps/web/modules/videos/ai/ai-transcribe.tsx +++ b/apps/web/modules/videos/ai/ai-transcribe.tsx @@ -152,9 +152,10 @@ export const CalAiTranscribe = () => { id="cal-ai-transcription" style={{ textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black", + backgroundColor: "rgba(0,0,0,0.6)", }} ref={transcriptRef} - className="max-h-full overflow-x-hidden overflow-y-scroll p-2 text-center text-white"> + className="flex max-h-full justify-center overflow-x-hidden overflow-y-scroll p-2 text-center text-white"> {transcript ? transcript.split("\n").map((line, i) => ( diff --git a/apps/web/modules/videos/views/videos-single-view.tsx b/apps/web/modules/videos/views/videos-single-view.tsx index 95dac5e05bb8ab..e40e8ce105e2eb 100644 --- a/apps/web/modules/videos/views/videos-single-view.tsx +++ b/apps/web/modules/videos/views/videos-single-view.tsx @@ -94,7 +94,9 @@ export default function JoinCall(props: PageProps) { -
    +
    From 050abd9fab3df509e0be8917b28d55d4d88ca514 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 14 Aug 2024 20:24:23 +0200 Subject: [PATCH 06/12] Update pr-review.yml (#16100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Omar López --- .github/workflows/pr-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index d18a871ef8d417..dd32541fddf1bb 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -13,5 +13,5 @@ jobs: if: github.event.review.state == 'approved' uses: actions-ecosystem/action-add-labels@v1 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.READY_FOR_E2E_PAT }} labels: 'ready-for-e2e' From 5939a99aca0810f45591ebc826ad75a1fcc4eac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 14 Aug 2024 12:44:10 -0700 Subject: [PATCH 07/12] test: merge all e2e reports (#16202) --- .github/workflows/e2e-app-store.yml | 4 ++-- .github/workflows/e2e-embed-react.yml | 4 ++-- .github/workflows/e2e-embed.yml | 4 ++-- .github/workflows/pr.yml | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index e0b36f40cc5eb0..a74e61b595cc7f 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -98,5 +98,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: app-store-results - path: test-results + name: blob-report-app-store + path: blob-report diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index 4f114f5ef8d7c9..f0a9c5ab298020 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -85,5 +85,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: embed-react-results - path: test-results + name: blob-report-embed-react + path: blob-report diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 05d7015b8808c5..57b922e6b8716b 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -91,5 +91,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: embed-core-results - path: test-results + name: blob-report-embed-core + path: blob-report diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d5bd3d4dd2e0b0..de24b26cf9bf6f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -189,19 +189,19 @@ jobs: merge-reports: name: Merge reports - if: ${{ !cancelled() }} - needs: [e2e] + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' }} + needs: [check-label, e2e] uses: ./.github/workflows/merge-reports.yml secrets: inherit publish-report: name: Publish HTML report - if: ${{ !cancelled() }} + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' }} permissions: contents: write issues: write pull-requests: write - needs: [merge-reports] + needs: [check-label, merge-reports] uses: ./.github/workflows/publish-report.yml secrets: inherit From a11242e93d827424dfe7f3f74f3189a0e1efbb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 14 Aug 2024 14:41:46 -0700 Subject: [PATCH 08/12] fix: missing pipeline dependecies (#16205) --- .github/workflows/pr.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index de24b26cf9bf6f..eb2998c2fa86e1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -189,19 +189,19 @@ jobs: merge-reports: name: Merge reports - if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' }} - needs: [check-label, e2e] + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store] uses: ./.github/workflows/merge-reports.yml secrets: inherit publish-report: name: Publish HTML report - if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' }} + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} permissions: contents: write issues: write pull-requests: write - needs: [check-label, merge-reports] + needs: [changes, check-label, merge-reports] uses: ./.github/workflows/publish-report.yml secrets: inherit From 84606543aecf40b27aae4098b38ec357839384b0 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Thu, 15 Aug 2024 01:11:46 +0300 Subject: [PATCH 09/12] chore: preserve non-org dashboard for platform users (#16181) --- apps/web/public/icons/sprite.svg | 4 +++ .../features/auth/lib/next-auth-options.ts | 24 ++++++++------- .../features/eventtypes/lib/getPublicEvent.ts | 23 +++++++++++++- packages/features/shell/Shell.tsx | 30 ++++++++++++++----- packages/lib/server/getBrand.ts | 7 +++++ packages/lib/server/repository/profile.ts | 8 +++++ packages/lib/server/repository/user.ts | 11 +++++++ .../routers/loggedInViewer/me.handler.ts | 23 ++++++++++---- packages/ui/components/icon/icon-list.mjs | 1 + packages/ui/components/icon/icon-names.ts | 1 + 10 files changed, 107 insertions(+), 25 deletions(-) diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 0ae3cf4bf03ac6..e50f98860f1532 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -51,6 +51,10 @@ + + + + diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index dff904dc2774b5..2e645671ca6f58 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -531,17 +531,19 @@ export const AUTH_OPTIONS: AuthOptions = { belongsToActiveTeam, // All organizations in the token would be too big to store. It breaks the sessions request. // So, we just set the currently switched organization only here. - org: profileOrg - ? { - id: profileOrg.id, - name: profileOrg.name, - slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "", - logoUrl: profileOrg.logoUrl, - fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""), - domainSuffix: subdomainSuffix(), - role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg - } - : null, + // platform org user don't need profiles nor domains + org: + profileOrg && !profileOrg.isPlatform + ? { + id: profileOrg.id, + name: profileOrg.name, + slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "", + logoUrl: profileOrg.logoUrl, + fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""), + domainSuffix: subdomainSuffix(), + role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg + } + : null, } as JWT; }; if (!user) { diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 570bd5e14f7a4e..68ae1973dc5fa5 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -248,7 +248,7 @@ export const getPublicEvent = async ( }; // In case it's not a group event, it's either a single user or a team, and we query that data. - const event = await prisma.eventType.findFirst({ + let event = await prisma.eventType.findFirst({ where: { slug: eventSlug, ...usersOrTeamQuery, @@ -256,6 +256,27 @@ export const getPublicEvent = async ( select: publicEventSelect, }); + // If no event was found, check for platform org user event + if (!event && !orgQuery) { + event = await prisma.eventType.findFirst({ + where: { + slug: eventSlug, + users: { + some: { + username, + isPlatformManaged: false, + movedToProfile: { + organization: { + isPlatform: true, + }, + }, + }, + }, + }, + select: publicEventSelect, + }); + } + if (!event) return null; const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 3a9dadb70b5ad1..dc575f53849b9d 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -384,7 +384,8 @@ function UserDropdown({ small }: UserDropdownProps) { const { data: user } = useMeQuery(); const utils = trpc.useUtils(); const bookerUrl = useBookerUrl(); - + const pathname = usePathname(); + const isPlatformPages = pathname?.startsWith("/settings/platform"); useEffect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -468,7 +469,7 @@ function UserDropdown({ small }: UserDropdownProps) { onHelpItemSelect()} /> ) : ( <> - {!isPlatformUser && ( + {!isPlatformPages && ( <> - {!isPlatformUser && ( + {!isPlatformPages && ( )} + {!isPlatformPages && ( + + + Platform + + + )} @@ -912,9 +924,11 @@ function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarCont return ; } -function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) { +function SideBar({ bannersHeight, user }: SideBarProps) { const { t, isLocaleReady } = useLocale(); const orgBranding = useOrgBranding(); + const pathname = usePathname(); + const isPlatformPages = pathname?.startsWith("/settings/platform"); const publicPageUrl = useMemo(() => { if (!user?.org?.id) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`; @@ -953,10 +967,10 @@ function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) return (
  • @@ -97,12 +106,20 @@ export const CheckedTeamSelect = ({ ))} {currentOption && !currentOption.isFixed ? ( - + <> + + + ) : ( <> )} @@ -110,67 +127,6 @@ export const CheckedTeamSelect = ({ ); }; -interface IPriiorityDialog { - isOpenDialog: boolean; - setIsOpenDialog: Dispatch>; - option: CheckedSelectOption; - onChange: (value: readonly CheckedSelectOption[]) => void; -} - -const PriorityDialog = (props: IPriiorityDialog) => { - const { t } = useLocale(); - const { isOpenDialog, setIsOpenDialog, option, onChange } = props; - const { getValues } = useFormContext(); - - const priorityOptions = [ - { label: t("lowest"), value: 0 }, - { label: t("low"), value: 1 }, - { label: t("medium"), value: 2 }, - { label: t("high"), value: 3 }, - { label: t("highest"), value: 4 }, - ]; - - const [newPriority, setNewPriority] = useState<{ label: string; value: number }>(); - const setPriority = () => { - if (!!newPriority) { - const hosts: Host[] = getValues("hosts"); - const updatedHosts = hosts - .filter((host) => !host.isFixed) - .map((host) => { - return { - ...option, - value: host.userId.toString(), - priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority, - isFixed: false, - }; - }) - .sort((a, b) => b.priority ?? 2 - a.priority ?? 2); - onChange(updatedHosts); - } - setIsOpenDialog(false); - }; - return ( - - -
    - - setNewPriority(value ?? priorityOptions[2])} + options={priorityOptions} + /> +
    + + + + + +
    +
    + ); +}; + +export const weightDescription = ( + + Weights determine how meetings are distributed among hosts. + + Learn more + + +); + +export function sortHosts( + hostA: { priority: number | null; weight: number | null }, + hostB: { priority: number | null; weight: number | null }, + isRRWeightsEnabled: boolean +) { + const weightA = hostA.weight ?? 100; + const priorityA = hostA.priority ?? 2; + const weightB = hostB.weight ?? 100; + const priorityB = hostB.priority ?? 2; + + if (isRRWeightsEnabled) { + if (weightA === weightB) { + return priorityB - priorityA; + } else { + return weightB - weightA; + } + } else { + return priorityB - priorityA; + } +} + +export const WeightDialog = (props: IDialog) => { + const { t } = useLocale(); + const { isOpenDialog, setIsOpenDialog, option, onChange } = props; + const { getValues } = useFormContext(); + const [newWeight, setNewWeight] = useState(100); + + const setWeight = () => { + const hosts: Host[] = getValues("hosts"); + const updatedHosts = hosts + .filter((host) => !host.isFixed) + .map((host) => { + return { + ...option, + value: host.userId.toString(), + priority: host.priority, + weight: host.userId === parseInt(option.value, 10) ? newWeight : host.weight, + isFixed: false, + weightAdjustment: host.weightAdjustment, + }; + }); + + const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, true)); + + onChange(sortedHosts); + setIsOpenDialog(false); + }; + + return ( + + +
    + +
    + setNewWeight(parseInt(e.target.value))} + addOnSuffix={<>%} + /> +
    +
    + + + + +
    +
    + ); +}; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 4aea36d1de8898..3a9040cd682354 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -20,7 +20,13 @@ export type AvailabilityOption = { }; export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; -export type Host = { isFixed: boolean; userId: number; priority: number }; +export type Host = { + isFixed: boolean; + userId: number; + priority: number; + weight: number; + weightAdjustment: number; +}; export type TeamMember = { value: string; label: string; @@ -122,6 +128,7 @@ export type FormValues = { useEventTypeDestinationCalendarEmail: boolean; forwardParamsSuccessRedirect: boolean | null; secondaryEmailId?: number; + isRRWeightsEnabled: boolean; }; export type LocationFormValues = Pick; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 6c6cb244f589db..f28cebcaf1f4c9 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -104,6 +104,7 @@ const commons = { metadata: EventTypeMetaDataSchema.parse({}), bookingFields: [], assignAllTeamMembers: false, + isRRWeightsEnabled: false, rescheduleWithSameRoundRobinHost: false, useEventTypeDestinationCalendarEmail: false, secondaryEmailId: null, diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 29d68c42ab9b69..a1dab4460cc28b 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -43,6 +43,7 @@ export const eventTypeSelect = Prisma.validator()({ instantMeetingExpiryTimeOffsetInSeconds: true, aiPhoneCallConfig: true, assignAllTeamMembers: true, + isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, recurringEvent: true, locations: true, diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index b563dcb1db459b..454c4a7db67ef0 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -104,7 +104,11 @@ describe("getLuckyUser tests", () => { expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp], - eventTypeId, + eventType: { + id: eventTypeId, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(organizerThatDidntShowUp); }); @@ -170,7 +174,11 @@ describe("getLuckyUser tests", () => { expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp], - eventTypeId, + eventType: { + id: eventTypeId, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(organizerWhoseAttendeeDidntShowUp); }); @@ -289,7 +297,11 @@ describe("getLuckyUser tests", () => { fixedHostOrganizerWhoseAttendeeDidNotShowUp, organizerWhoWasAttendeeAndDidntShowUp, ], - eventTypeId, + eventType: { + id: eventTypeId, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(organizerWhoWasAttendeeAndDidntShowUp); }); @@ -356,7 +368,11 @@ describe("getLuckyUser tests", () => { expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: [user1, user2], - eventTypeId, + eventType: { + id: eventTypeId, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(user2); }); diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index f679f1f4d45fba..e9e336f440fed4 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -1,14 +1,31 @@ import type { User } from "@prisma/client"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import prisma from "@calcom/prisma"; +import type { Booking } from "@calcom/prisma/client"; +import { BookingStatus } from "@calcom/prisma/enums"; -async function leastRecentlyBookedUser>({ - availableUsers, - eventTypeId, -}: { +type PartialBooking = Pick & { + attendees: { email: string | null }[]; +}; + +type PartialUser = Pick; + +interface GetLuckyUserParams { availableUsers: T[]; - eventTypeId: number; -}) { + eventType: { id: number; isRRWeightsEnabled: boolean }; + allRRHosts: { + user: { id: number; email: string }; + weight?: number | null; + weightAdjustment?: number | null; + }[]; +} + +async function leastRecentlyBookedUser({ + availableUsers, + eventType, + bookingsOfAvailableUsers, +}: GetLuckyUserParams & { bookingsOfAvailableUsers: PartialBooking[] }) { // First we get all organizers (fixed host/single round robin user) const organizersWithLastCreated = await prisma.user.findMany({ where: { @@ -23,7 +40,8 @@ async function leastRecentlyBookedUser>({ createdAt: true, }, where: { - eventTypeId, + eventTypeId: eventType.id, + status: BookingStatus.ACCEPTED, attendees: { some: { noShow: false, @@ -56,48 +74,19 @@ async function leastRecentlyBookedUser>({ {} ); - const bookings = await prisma.booking.findMany({ - where: { - AND: [ - { - eventTypeId, - }, - { - attendees: { - some: { - email: { - in: availableUsers.map((user) => user.email), - }, - noShow: false, - }, - }, - }, - ], - }, - select: { - id: true, - createdAt: true, - attendees: { - select: { - email: true, - }, - }, + const attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.reduce( + (aggregate: { [userId: number]: Date }, booking) => { + availableUsers.forEach((user) => { + if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate + // contains the user id, it's already got the most recent booking marked. + if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return; + if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings + aggregate[user.id] = booking.createdAt; + }); + return aggregate; }, - orderBy: { - createdAt: "desc", - }, - }); - - const attendeeUserIdAndAtCreatedPair = bookings.reduce((aggregate: { [userId: number]: Date }, booking) => { - availableUsers.forEach((user) => { - if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate - // contains the user id, it's already got the most recent booking marked. - if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return; - if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings - aggregate[user.id] = booking.createdAt; - }); - return aggregate; - }, {}); + {} + ); const userIdAndAtCreatedPair = { ...organizerIdAndAtCreatedPair, @@ -118,7 +107,7 @@ async function leastRecentlyBookedUser>({ return leastRecentlyBookedUser; } -function getUsersWithHighestPriority & { priority?: number | null }>({ +function getUsersWithHighestPriority({ availableUsers, }: { availableUsers: T[]; @@ -130,18 +119,130 @@ function getUsersWithHighestPriority & { pr ); } +async function getUsersBasedOnWeights< + T extends PartialUser & { + weight?: number | null; + weightAdjustment?: number | null; + } +>({ + availableUsers, + bookingsOfAvailableUsers, + allRRHosts, + eventType, +}: GetLuckyUserParams & { bookingsOfAvailableUsers: PartialBooking[] }) { + //get all bookings of all other RR hosts that are not available + const availableUserIds = new Set(availableUsers.map((user) => user.id)); + + const notAvailableHosts = allRRHosts.reduce( + ( + acc: { + id: number; + email: string; + }[], + host + ) => { + if (!availableUserIds.has(host.user.id)) { + acc.push({ + id: host.user.id, + email: host.user.email, + }); + } + return acc; + }, + [] + ); + + const bookingsOfNotAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({ + eventTypeId: eventType.id, + users: notAvailableHosts, + }); + + const allBookings = bookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsers); + + // Calculate the total weightAdjustments and weight of all round-robin hosts + const { allWeightAdjustments, totalWeight } = allRRHosts.reduce( + (acc, host) => { + acc.allWeightAdjustments += host.weightAdjustment ?? 0; + acc.totalWeight += host.weight ?? 100; + return acc; + }, + { allWeightAdjustments: 0, totalWeight: 0 } + ); + + // Calculate booking shortfall for each available user + const usersWithBookingShortfalls = availableUsers.map((user) => { + const targetPercentage = (user.weight ?? 100) / totalWeight; + + const userBookings = bookingsOfAvailableUsers.filter( + (booking) => + booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email) + ); + + const targetNumberOfBookings = (allBookings.length + allWeightAdjustments) * targetPercentage; + const bookingShortfall = targetNumberOfBookings - (userBookings.length + (user.weightAdjustment ?? 0)); + + return { + ...user, + bookingShortfall, + }; + }); + + // Find users with the highest booking shortfall + const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall)); + const usersWithMaxShortfall = usersWithBookingShortfalls.filter( + (user) => user.bookingShortfall === maxShortfall + ); + + // ff more user's were found, find users with highest weights + const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100)); + + const userIdsWithMaxShortfallAndWeight = new Set( + usersWithMaxShortfall.filter((user) => user.weight === maxWeight).map((user) => user.id) + ); + + return availableUsers.filter((user) => userIdsWithMaxShortfallAndWeight.has(user.id)); +} + // TODO: Configure distributionAlgorithm from the event type configuration // TODO: Add 'MAXIMIZE_FAIRNESS' algorithm. -export async function getLuckyUser & { priority?: number | null }>( +export async function getLuckyUser< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + weightAdjustment?: number | null; + } +>( distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY", - { availableUsers, eventTypeId }: { availableUsers: T[]; eventTypeId: number } + getLuckyUserParams: GetLuckyUserParams ) { + const { availableUsers, eventType, allRRHosts } = getLuckyUserParams; + if (availableUsers.length === 1) { return availableUsers[0]; } + + const bookingsOfAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({ + eventTypeId: eventType.id, + users: availableUsers.map((user) => { + return { id: user.id, email: user.email }; + }), + }); + switch (distributionAlgorithm) { case "MAXIMIZE_AVAILABILITY": - const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); - return leastRecentlyBookedUser({ availableUsers: highestPriorityUsers, eventTypeId }); + let possibleLuckyUsers = availableUsers; + if (eventType.isRRWeightsEnabled) { + possibleLuckyUsers = await getUsersBasedOnWeights({ + ...getLuckyUserParams, + bookingsOfAvailableUsers, + }); + } + const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers: possibleLuckyUsers }); + + return leastRecentlyBookedUser({ + ...getLuckyUserParams, + availableUsers: highestPriorityUsers, + bookingsOfAvailableUsers, + }); } } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index b58d75a86bc968..8e054088fd8482 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -1,4 +1,7 @@ -import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; import { UserRepository } from "./user"; @@ -54,6 +57,62 @@ export class BookingRepository { }); } + static async getAllBookingsForRoundRobin({ + users, + eventTypeId, + }: { + users: { id: number; email: string }[]; + eventTypeId: number; + }) { + const whereClause: Prisma.BookingWhereInput = { + OR: [ + { + user: { + id: { + in: users.map((user) => user.id), + }, + }, + OR: [ + { + noShowHost: false, + }, + { + noShowHost: null, + }, + ], + }, + { + attendees: { + some: { + email: { + in: users.map((user) => user.email), + }, + }, + }, + }, + ], + attendees: { some: { noShow: false } }, + status: BookingStatus.ACCEPTED, + eventTypeId, + }; + + const allBookings = await prisma.booking.findMany({ + where: whereClause, + select: { + id: true, + attendees: true, + userId: true, + createdAt: true, + status: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return allBookings; + } + static async findBookingByUidAndUserId({ bookingUid, userId }: { bookingUid: string; userId: number }) { return await prisma.booking.findFirst({ where: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index e1a6110c6c9655..f60e0a9f6bbb9d 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -457,6 +457,7 @@ export class EventTypeRepository { onlyShowFirstAvailableSlot: true, durationLimits: true, assignAllTeamMembers: true, + isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, successRedirectUrl: true, forwardParamsSuccessRedirect: true, @@ -524,6 +525,8 @@ export class EventTypeRepository { isFixed: true, userId: true, priority: true, + weight: true, + weightAdjustment: true, }, }, userId: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 7e33ea50614662..48e8053276098b 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -33,7 +33,7 @@ export const buildPerson = (person?: Partial): Person => { export const buildBooking = ( booking?: Partial & { references?: Partial[] } -): Booking & { references?: Partial[] } => { +): Booking & { references?: Partial[]; attendees?: [] } => { const uid = faker.datatype.uuid(); return { id: faker.datatype.number(), @@ -70,6 +70,7 @@ export const buildBooking = ( rating: null, noShowHost: null, ratingFeedback: null, + attendees: [], ...booking, }; }; @@ -126,6 +127,7 @@ export const buildEventType = (eventType?: Partial): EventType => { parentId: null, profileId: null, secondaryEmailId: null, + isRRWeightsEnabled: false, eventTypeColor: null, ...eventType, }; @@ -250,8 +252,8 @@ type UserPayload = Prisma.UserGetPayload<{ }; }>; export const buildUser = >( - user?: T & { priority?: number } -): UserPayload & { priority: number | null } => { + user?: T & { priority?: number; weight?: number; weightAdjustment?: number } +): UserPayload & { priority: number; weight: number; weightAdjustment: number } => { return { locked: false, smsLockState: "UNLOCKED", @@ -298,7 +300,9 @@ export const buildUser = >( allowSEOIndexing: null, receiveMonthlyDigestEmail: null, movedToProfileId: null, - priority: user?.priority ?? null, + priority: user?.priority ?? 2, + weight: user?.weight ?? 100, + weightAdjustment: user?.weightAdjustment ?? 0, isPlatformManaged: false, ...user, }; diff --git a/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql b/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql new file mode 100644 index 00000000000000..a06969c16417eb --- /dev/null +++ b/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "isRRWeightsEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Host" ADD COLUMN "weight" INTEGER, +ADD COLUMN "weightAdjustment" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cb005a2d39c595..3d8f8d9c6bb8f1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -43,12 +43,15 @@ enum PeriodType { } model Host { - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) - eventTypeId Int - isFixed Boolean @default(false) - priority Int? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) + priority Int? + weight Int? + // amount of fake bookings to be added when calculating the weight (for new hosts, OOO, etc.) + weightAdjustment Int? @@id([userId, eventTypeId]) @@index([userId]) @@ -137,9 +140,11 @@ model EventType { assignAllTeamMembers Boolean @default(false) useEventTypeDestinationCalendarEmail Boolean @default(false) aiPhoneCallConfig AIPhoneCallConfiguration? + isRRWeightsEnabled Boolean @default(false) + /// @zod.custom(imports.eventTypeColor) - eventTypeColor Json? - rescheduleWithSameRoundRobinHost Boolean @default(false) + eventTypeColor Json? + rescheduleWithSameRoundRobinHost Boolean @default(false) secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index eca0100aab6bb3..78b249c1d671e0 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -670,6 +670,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit; type User = { @@ -67,6 +72,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { offsetStart, secondaryEmailId, aiPhoneCallConfig, + isRRWeightsEnabled, ...rest } = input; @@ -74,6 +80,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { where: { id }, select: { title: true, + isRRWeightsEnabled: true, aiPhoneCallConfig: { select: { generalPrompt: true, @@ -137,6 +144,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const data: Prisma.EventTypeUpdateInput = { ...rest, bookingFields, + isRRWeightsEnabled, metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject), }; @@ -248,14 +256,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { code: "FORBIDDEN", }); } + + // weights were already enabled or are enabled now + const isWeightsEnabled = + isRRWeightsEnabled || (typeof isRRWeightsEnabled === "undefined" && eventType.isRRWeightsEnabled); + + const hostsWithWeightAdjustment = await addWeightAdjustmentToNewHosts({ + hosts, + isWeightsEnabled, + eventTypeId: id, + prisma: ctx.prisma, + }); + data.hosts = { deleteMany: {}, - create: hosts.map((host) => { + create: hostsWithWeightAdjustment.map((host) => { const { ...rest } = host; return { ...rest, isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, priority: host.priority ?? 2, // default to medium priority + weight: host.weight ?? 100, + weightAdjustment: host.weightAdjustment, }; }), }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index 1418fe952c1296..a4891c05a75aee 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; import { UserRepository } from "@calcom/lib/server/repository/user"; +import type { PrismaClient } from "@calcom/prisma"; import { MembershipRole, PeriodType } from "@calcom/prisma/enums"; import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -152,6 +154,129 @@ export function ensureUniqueBookingFields(fields: z.infer); } +type Host = { + userId: number; + isFixed?: boolean | undefined; + priority?: number | null | undefined; + weight?: number | null | undefined; +}; + +type User = { + id: number; + email: string; +}; + +export async function addWeightAdjustmentToNewHosts({ + hosts, + isWeightsEnabled, + eventTypeId, + prisma, +}: { + hosts: Host[]; + isWeightsEnabled: boolean; + eventTypeId: number; + prisma: PrismaClient; +}): Promise<(Host & { weightAdjustment?: number })[]> { + if (!isWeightsEnabled) return hosts; + + // to also have the user email to check for attendees + const usersWithHostData = await prisma.user.findMany({ + where: { + id: { + in: hosts.map((host) => host.userId), + }, + }, + select: { + email: true, + id: true, + hosts: { + where: { + eventTypeId, + }, + select: { + isFixed: true, + weightAdjustment: true, + priority: true, + weight: true, + }, + }, + }, + }); + + const hostsWithUserData = usersWithHostData.map((user) => { + // user.hosts[0] is the previous host data from the db + // hostData is the new host data + const hostData = hosts.find((host) => host.userId === user.id); + return { + isNewRRHost: !hostData?.isFixed && (!user.hosts.length || user.hosts[0].isFixed), + isFixed: hostData?.isFixed ?? false, + weightAdjustment: hostData?.isFixed ? 0 : user.hosts[0]?.weightAdjustment ?? 0, + priority: hostData?.priority ?? 2, + weight: hostData?.weight ?? 100, + user: { + id: user.id, + email: user.email, + }, + }; + }); + + const ongoingRRHosts = hostsWithUserData.filter((host) => !host.isFixed && !host.isNewRRHost); + const allRRHosts = hosts.filter((host) => !host.isFixed); + + if (ongoingRRHosts.length === allRRHosts.length) { + //no new RR host was added + return hostsWithUserData.map((host) => ({ + userId: host.user.id, + isFixed: host.isFixed, + priority: host.priority, + weight: host.weight, + weightAdjustment: host.weightAdjustment, + })); + } + + const ongoingHostBookings = await BookingRepository.getAllBookingsForRoundRobin({ + eventTypeId, + users: ongoingRRHosts.map((host) => { + return { id: host.user.id, email: host.user.email }; + }), + }); + + const { ongoingHostsWeightAdjustment, ongoingHostsWeights } = ongoingRRHosts.reduce( + (acc, host) => { + acc.ongoingHostsWeightAdjustment += host.weightAdjustment ?? 0; + acc.ongoingHostsWeights += host.weight ?? 0; + return acc; + }, + { ongoingHostsWeightAdjustment: 0, ongoingHostsWeights: 0 } + ); + + const hostsWithWeightAdjustments = await Promise.all( + hostsWithUserData.map(async (host) => { + let weightAdjustment = !host.isFixed ? host.weightAdjustment : 0; + if (host.isNewRRHost) { + // host can already have bookings, if they ever was assigned before + const existingBookings = await BookingRepository.getAllBookingsForRoundRobin({ + eventTypeId, + users: [{ id: host.user.id, email: host.user.email }], + }); + + const proportionalNrOfBookings = + ((ongoingHostBookings.length + ongoingHostsWeightAdjustment) / ongoingHostsWeights) * host.weight; + weightAdjustment = proportionalNrOfBookings - existingBookings.length; + } + + return { + userId: host.user.id, + isFixed: host.isFixed, + priority: host.priority, + weight: host.weight, + weightAdjustment: weightAdjustment > 0 ? Math.floor(weightAdjustment) : 0, + }; + }) + ); + + return hostsWithWeightAdjustments; +} export const mapEventType = async (eventType: EventType) => ({ ...eventType, safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,