From 2144fcb23e6abba42f7d1b81952f15692421eeec Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 20 Nov 2023 16:34:29 +0530 Subject: [PATCH 01/35] fix: add node-mocks-http to web (#12435) --- apps/web/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index d9ad6f022d..e565cb0d4e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -168,6 +168,7 @@ "module-alias": "^2.2.2", "msw": "^0.42.3", "node-html-parser": "^6.1.10", + "node-mocks-http": "^1.11.0", "postcss": "^8.4.18", "tailwindcss": "^3.3.1", "tailwindcss-animate": "^1.0.6", diff --git a/yarn.lock b/yarn.lock index f03d7d2160..4e231a4e0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4604,6 +4604,7 @@ __metadata: next-seo: ^6.0.0 next-themes: ^0.2.0 node-html-parser: ^6.1.10 + node-mocks-http: ^1.11.0 nodemailer: ^6.7.8 otplib: ^12.0.1 postcss: ^8.4.18 From 9a4c20cca457efa666b5a74fd15ea541ecee0db5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 20 Nov 2023 11:08:01 +0000 Subject: [PATCH 02/35] New Crowdin translations by Github Action --- apps/web/public/static/locales/he/common.json | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index c98de025cd..fdb11fce61 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -268,6 +268,7 @@ "set_availability": "ציין את הזמינות שלך", "availability_settings": "הגדרות זמינוּת", "continue_without_calendar": "להמשיך בלי לוח שנה", + "continue_with": "להמשיך עם {{appName}}", "connect_your_calendar": "קשר את לוח השנה שלך", "connect_your_video_app": "חיבור אפליקציות הווידאו שלך", "connect_your_video_app_instructions": "חבר/י את אפליקציות הווידאו שלך כדי להשתמש בהן עבור סוגי האירועים שלך.", @@ -288,6 +289,8 @@ "when": "מתי", "where": "היכן", "add_to_calendar": "הוספה ללוח השנה", + "add_to_calendar_description": "בחר/י להיכן יש להוסיף אירועים כשאת/ה עסוק/ה.", + "add_events_to": "הוספת אירועים ל", "add_another_calendar": "להוסיף לוח שנה אחר", "other": "אחר", "email_sign_in_subject": "קישור הכניסה שלך אל {{appName}}", @@ -422,6 +425,7 @@ "booking_created": "ההזמנה נוצרה", "booking_rejected": "ההזמנה נדחתה", "booking_requested": "התקבלה בקשת הזמנה", + "booking_payment_initiated": "הופעל תשלום על ההזמנה", "meeting_ended": "הפגישה הסתיימה", "form_submitted": "הטופס נשלח", "booking_paid": "בוצע תשלום עבור ההזמנה", @@ -456,6 +460,7 @@ "no_event_types_have_been_setup": "משתמש זה עדיין לא הגדיר סוג אירוע.", "edit_logo": "עריכת לוגו", "upload_a_logo": "העלאת לוגו", + "upload_logo": "העלאת לוגו", "remove_logo": "הסרת לוגו", "enable": "הפעלה", "code": "קוד", @@ -568,6 +573,7 @@ "your_team_name": "שם הצוות שלך", "team_updated_successfully": "עדכון הצוות בוצע בהצלחה", "your_team_updated_successfully": "הצוות שלך עודכן בהצלחה.", + "your_org_updated_successfully": "הארגון שלך עודכן בהצלחה.", "about": "אודות", "team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.", "org_description": "מספר משפטים אודות הארגון. הם יופיעו בדף עם כתובת ה-URL של הארגון.", @@ -599,6 +605,7 @@ "hide_book_a_team_member": "הסתרת הלחצן לשריון זמן של חבר/ת צוות", "hide_book_a_team_member_description": "הסתר/י את הלחצן לשריון זמן של חבר/ת צוות מהדפים הציבוריים שלך.", "danger_zone": "אזור מסוכן", + "account_deletion_cannot_be_undone": "יש לנקוט זהירות. מחיקת חשבון היא פעולה בלתי הפיכה.", "back": "הקודם", "cancel": "ביטול", "cancel_all_remaining": "לבטל את כל הנותרים", @@ -688,6 +695,7 @@ "people": "אנשים", "your_email": "הדוא\"ל שלך", "change_avatar": "שינוי אווטאר", + "upload_avatar": "העלאת אווטאר", "language": "שפה", "timezone": "אזור זמן", "first_day_of_week": "היום הראשון בשבוע", @@ -778,6 +786,7 @@ "disable_guests": "השבתת אורחים", "disable_guests_description": "השבת את האפשרות להוסיף אורחים נוספים בעת ביצוע הזמנה.", "private_link": "יצירת קישור פרטי", + "enable_private_url": "לאפשר כתובת URL פרטית", "private_link_label": "קישור פרטי", "private_link_hint": "הקישור הפרטי שלך ייווצר מחדש לאחר כל שימוש", "copy_private_link": "העתקת קישור פרטי", @@ -1214,6 +1223,7 @@ "organizer_name_variable": "שם המארגן/ת", "app_upgrade_description": "כדי להשתמש בתכונה זו, עליך לשדרג לחשבון Pro.", "invalid_number": "מספר טלפון לא תקין", + "invalid_url_error_message": "כתובת URL לא חוקית עבור {{label}}. כתובת URL לדוגמה: {{sampleUrl}}", "navigate": "ניווט", "open": "פתח", "close": "סגירה", @@ -1277,6 +1287,7 @@ "personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}", "bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.", "user_has_no_bio": "משתמש זה עדיין לא הוסיף ביוגרפיה.", + "bio": "ביוגרפיה", "delete_account_modal_title": "מחיקת החשבון", "confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?", "delete_my_account": "מחיקת החשבון שלי", @@ -1287,6 +1298,7 @@ "select_calendars": "בחר את לוחות השנה שבהם ברצונך לבדוק אם יש התנגשויות, כדי למנוע כפל הזמנות.", "check_for_conflicts": "בדיקת התנגשויות", "view_recordings": "צפייה בהקלטות", + "check_for_recordings": "חיפוש הקלטות", "adding_events_to": "הוספת אירועים ל", "follow_system_preferences": "פעל לפי העדפות המערכת", "custom_brand_colors": "צבעי מותג בהתאמה אישית", @@ -1531,6 +1543,7 @@ "problem_registering_domain": "הייתה בעיה ברישום תת-הדומיין; אפשר לנסות שוב או לפנות למנהל/ת מערכת", "team_publish": "פרסום צוות", "number_text_notifications": "מספר טלפון (להודעות טקסט)", + "number_sms_notifications": "מספר טלפון (להודעות SMS)", "attendee_email_variable": "כתובת הדוא\"ל של המשתתף", "attendee_email_info": "כתובת הדוא\"ל של האדם שביצע את ההזמנה", "kbar_search_placeholder": "הקלד/י פקודה או חפש/י...", @@ -1595,6 +1608,7 @@ "options": "אפשרויות", "enter_option": "הזנת ה-{{index}} של האפשרות", "add_an_option": "הוספת אפשרות", + "location_already_exists": "המיקום הזה כבר קיים, יש לבחור מיקום חדש", "radio": "רדיו", "google_meet_warning": "כדי להשתמש ב-Google Meet, יש להגדיר את Google Calendar כלוח השנה של המארח", "individual": "משתמש בודד", @@ -1614,6 +1628,7 @@ "date_overrides_mark_all_day_unavailable_other": "סימון אי-זמינות בתאריכים מסוימים", "date_overrides_add_btn": "הוספת מעקף", "date_overrides_update_btn": "עדכון מעקף", + "date_successfully_added": "עקיפת תאריך נוספה בהצלחה", "event_type_duplicate_copy_text": "{{slug}}-עותק", "set_as_default": "להגדיר כברירת מחדל", "hide_eventtype_details": "הסתרת פרטי סוג האירוע", @@ -1640,6 +1655,7 @@ "minimum_round_robin_hosts_count": "מספר המארחים שחייבים להשתתף", "hosts": "מארחים", "upgrade_to_enable_feature": "אתה צריך לייצר צוות כדי להפעיל את היכולת. לחץ ליצירת צוות.", + "orgs_upgrade_to_enable_feature": "כדי לאפשר שימוש בתכונה הזו, יש לשדרג לתוכנית שלנו לארגונים.", "new_attendee": "משתתף/ת חדש/ה", "awaiting_approval": "בהמתנה לאישור", "requires_google_calendar": "האפליקציה הזו מחייבת חיבור ל-Google Calendar", @@ -1744,6 +1760,7 @@ "show_on_booking_page": "להציג בדף ההזמנות", "get_started_zapier_templates": "התחל עם תבניות Zapier", "team_is_unpublished": "צוות {{team}} אינו מפורסם", + "org_is_unpublished_description": "הקישור לארגון הזה אינו זמין כעת. יש ליצור קשר עם הבעלים של הארגון או לבקש מהם לפרסם אותו.", "team_is_unpublished_description": "קישור ה-{{entity}} הזה אינו זמין כעת. יש ליצור קשר עם הבעלים של ה-{{entity}} או לבקש מהם לפרסם אותו.", "team_member": "חבר צוות", "a_routing_form": "טופס ניתוב", @@ -1878,6 +1895,7 @@ "edit_invite_link": "עריכת הגדרות הקישור", "invite_link_copied": "קישור ההזמנה הועתק", "invite_link_deleted": "קישור ההזמנה נמחק", + "api_key_deleted": "מפתח API נמחק", "invite_link_updated": "הגדרות קישור ההזמנה נשמרו", "link_expires_after": "הקישורים מוגדרים לפוג לאחר...", "one_day": "יום אחד", @@ -2010,7 +2028,13 @@ "attendee_last_name_variable": "שם המשפחה של המשתתף", "attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה", "attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה", + "your_monthly_digest": "הסיכום החודשי שלך", + "member_name": "שם החבר/ה", + "most_popular_events": "האירועים הפופולריים ביותר", + "summary_of_events_for_your_team_for_the_last_30_days": "הנה הסיכום של האירועים הפופולריים של הצוות שלך, {{teamName}}, ל-30 הימים האחרונים", "me": "אני", + "monthly_digest_email": "אימייל עם סיכום חודשי", + "monthly_digest_email_for_teams": "אימייל עם סיכום חודשי עבור צוותים", "verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים", "member_removed": "החבר הוסר", "my_availability": "הזמינות שלי", @@ -2040,13 +2064,41 @@ "team_no_event_types": "אין לצוות זה אף סוג של אירוע", "seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים", "include_calendar_event": "כלילת אירוע מלוח השנה", + "oAuth": "OAuth", "recently_added": "נוספו לאחרונה", "no_members_found": "לא נמצא אף חבר", "event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.", "availability_schedules": "לוחות זמנים לזמינוּת", + "unauthorized": "אין הרשאה", + "access_cal_account": "{{clientName}} רוצה לקבל גישה לחשבון {{appName}} שלך", + "select_account_team": "בחירת חשבון או צוות", + "allow_client_to": "הדבר יאפשר ל-{{clientName}}:", + "associate_with_cal_account": "לשייך בינך לבין הפרטים האישיים שלך מ-{{clientName}}", + "see_personal_info": "לראות את הפרטים האישיים שלך, כולל פרטים אישיים שהגדרת כגלויים לכולם", + "see_primary_email_address": "לראות את כתובת הדוא\"ל הראשית שלך", + "connect_installed_apps": "להתחבר לאפליקציות המותקנות שלך", + "access_event_type": "לקרוא, לערוך ולמחוק את סוגי האירועים שלך", + "access_availability": "לקרוא, לערוך ולמחוק את הזמינות שלך", + "access_bookings": "לקרוא, לערוך ולמחוק את ההזמנות שלך", + "allow_client_to_do": "האם לאפשר ל-{{clientName}} לעשות זאת?", + "oauth_access_information": "לחיצה על 'אפשר' מהווה מתן הרשאה מצידך ליישום זה להשתמש במידע שלך בהתאם לתנאי השירות ולמדיניות הפרטיות שלו. ניתן לשלול את הגישה בחנות האפליקציות של {{appName}}.", + "allow": "אפשר", "view_only_edit_availability_not_onboarded": "משתמש זה לא השלים תהליך הטמעה. לא תהיה לך אפשרות להגדיר את הזמינות שלו עד שהוא יעשה זאת.", "view_only_edit_availability": "את/ה צופה בזמינות של משתמש זה. יש לך אפשרות לערוך רק את פרטי הזמינות שלך.", + "you_can_override_calendar_in_advanced_tab": "ניתן לעקוף זאת על בסיס כל אירוע לגופו בהגדרות המתקדמות בכל סוג אירוע.", "edit_users_availability": "עריכת הזמינות של משתמש: {{username}}", + "resend_invitation": "שליחת ההזמנה מחדש", + "invitation_resent": "ההזמנה נשלחה מחדש.", + "add_client": "הוספת לקוח", + "copy_client_secret_info": "לאחר העתקת הסוד, כבר לא תהיה לך אפשרות לראות אותו", + "add_new_client": "הוספת לקוח חדש", + "this_app_is_not_setup_already": "האפליקציה הזו עדיין לא הוגדרה", + "as_csv": "כ-CSV", + "overlay_my_calendar": "הצג את לוח השנה שלי בשכבת-על", + "overlay_my_calendar_toc": "על ידי חיבור אל לוח השנה שלך, את/ה מקבל/ת את מדיניות הפרטיות ואת תנאי השימוש שלנו. אפשר לשלול את הגישה בכל שלב.", + "view_overlay_calendar_events": "ראה/י את האירועים שלך בלוח השנה כדי למנוע התנגשות בהזמנות.", + "lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות", + "description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות – שימושי לאירועים אישיים.", "extensive_whitelabeling": "תהליך הטמעה והנדסת תמיכה אישי", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From bdd3b132d441a226cb0a0c477cc86fe34e1c3272 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:19:33 +0000 Subject: [PATCH 03/35] feat: troubleshooter with weekly view (V2) (#12280) * Inital UI + layout setup * use booker approach of grid * event-select - sidebar + store work * adds get schedule by event-type-slug * Calendar toggle * Load schedule from event slug * Add busy events to calendar * useschedule * Store more event info than just slug * Add date override to calendar * Changes sizes on smaller screens * add event title as a tooltip * Ensure header navigation works * Stop navigator throwing errors on inital render * Correct br * Event duration fixes * Add getMoreInfo if user is authed with current request.username * Add calendar color map wip * Add WIP comments for coloured outlines * Revert more info changes * Calculate date override correctly * Add description option * Fix inital schedule data not being populated * Nudge overlap over to make it clearer * Fix disabled state * WIP on math logic * Event list overlapping events logic * NIT about width * i18n + manage calendars link * Delete old troubleshooter * Update packages/features/calendars/weeklyview/components/event/EventList.tsx * Remove t-slots * Fix i18n & install calendar action * sm:imrovments * NITS * Fix types * fix: back button * Month prop null as we control from query param * Add head SEO * Fix headseo import * Fix date override tests --- apps/web/pages/availability/troubleshoot.tsx | 141 ++--------------- apps/web/playwright/availability.e2e.ts | 1 + apps/web/public/static/locales/en/common.json | 6 + packages/core/getCalendarsEvents.ts | 6 +- .../weeklyview/components/event/Event.tsx | 58 ++++--- .../weeklyview/components/event/EventList.tsx | 86 +++++++---- .../components/verticalLines/index.tsx | 8 +- .../calendars/weeklyview/state/store.ts | 4 +- .../calendars/weeklyview/types/events.ts | 4 + .../troubleshooter/Troubleshooter.tsx | 70 +++++++++ .../AvailabilitySchedulesContainer.tsx | 38 +++++ .../components/CalendarToggleContainer.tsx | 121 +++++++++++++++ .../components/ConnectedAppsContainer.tsx | 38 +++++ .../components/EventScheduleItem.tsx | 42 ++++++ .../components/EventTypeSelect.tsx | 53 +++++++ .../components/LargeCalendar.tsx | 142 ++++++++++++++++++ .../components/TroubleshooterHeader.tsx | 80 ++++++++++ .../TroubleshooterListItemContainer.tsx | 42 ++++++ .../components/TroubleshooterSidebar.tsx | 39 +++++ packages/features/troubleshooter/layout.tsx | 23 +++ packages/features/troubleshooter/store.ts | 110 ++++++++++++++ packages/features/troubleshooter/types.ts | 13 ++ .../viewer/availability/schedule/_router.tsx | 19 +++ .../getScheduleByEventTypeSlug.handler.ts | 69 +++++++++ .../getScheduleByEventTypeSlug.schema.ts | 7 + .../viewer/availability/user.handler.ts | 7 +- 26 files changed, 1038 insertions(+), 189 deletions(-) create mode 100644 packages/features/troubleshooter/Troubleshooter.tsx create mode 100644 packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx create mode 100644 packages/features/troubleshooter/components/CalendarToggleContainer.tsx create mode 100644 packages/features/troubleshooter/components/ConnectedAppsContainer.tsx create mode 100644 packages/features/troubleshooter/components/EventScheduleItem.tsx create mode 100644 packages/features/troubleshooter/components/EventTypeSelect.tsx create mode 100644 packages/features/troubleshooter/components/LargeCalendar.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterHeader.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterSidebar.tsx create mode 100644 packages/features/troubleshooter/layout.tsx create mode 100644 packages/features/troubleshooter/store.ts create mode 100644 packages/features/troubleshooter/types.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts diff --git a/apps/web/pages/availability/troubleshoot.tsx b/apps/web/pages/availability/troubleshoot.tsx index e3bfd24f0e..e37b598d68 100644 --- a/apps/web/pages/availability/troubleshoot.tsx +++ b/apps/web/pages/availability/troubleshoot.tsx @@ -1,139 +1,20 @@ -import dayjs from "@calcom/dayjs"; -import Shell from "@calcom/features/shell/Shell"; +import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter"; +import { getLayout } from "@calcom/features/troubleshooter/layout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { trpc } from "@calcom/trpc/react"; -import { SkeletonText } from "@calcom/ui"; - -import useRouterQuery from "@lib/hooks/useRouterQuery"; +import { HeadSeo } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -type User = RouterOutputs["viewer"]["me"]; - -export interface IBusySlot { - start: string | Date; - end: string | Date; - title?: string; - source?: string | null; -} - -const AvailabilityView = ({ user }: { user: User }) => { - const { t } = useLocale(); - const { date, setQuery: setSelectedDate } = useRouterQuery("date"); - const selectedDate = dayjs(date); - const formattedSelectedDate = selectedDate.format("YYYY-MM-DD"); - - const { data, isLoading } = trpc.viewer.availability.user.useQuery( - { - username: user.username || "", - dateFrom: selectedDate.startOf("day").utc().format(), - dateTo: selectedDate.endOf("day").utc().format(), - withSource: true, - }, - { - enabled: !!user.username, - } - ); - - const overrides = - data?.dateOverrides.reduce((acc, override) => { - if ( - formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") && - formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD") - ) - return acc; - acc.push({ ...override, source: "Date override" }); - return acc; - }, [] as IBusySlot[]) || []; - - return ( -
-
- {t("overview_of_day")}{" "} - { - if (e.target.value) setSelectedDate(e.target.value); - }} - /> - {t("hover_over_bold_times_tip")} -
-
-
- {t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)} -
-
- {(() => { - if (isLoading) - return ( - <> - - - - ); - - if (data && (data.busy.length > 0 || overrides.length > 0)) - return [...data.busy, ...overrides] - .sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1)) - .map((slot: IBusySlot) => ( -
-
- {t("calendar_shows_busy_between")}{" "} - - {dayjs(slot.start).format("HH:mm")} - {" "} - {t("and")}{" "} - - {dayjs(slot.end).format("HH:mm")} - {" "} - {t("on")} {dayjs(slot.start).format("D")}{" "} - {t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")} - {slot.title && ` - (${slot.title})`} - {slot.source && {` - (source: ${slot.source})`}} -
-
- )); - return ( -
-
{t("calendar_no_busy_slots")}
-
- ); - })()} - -
-
- {t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)} -
-
-
-
-
- ); -}; - -export default function Troubleshoot() { - const { data, isLoading } = trpc.viewer.me.useQuery(); +function TroubleshooterPage() { const { t } = useLocale(); return ( -
- - {!isLoading && data && } - -
+ <> + + + ); } -Troubleshoot.PageWrapper = PageWrapper; -function convertMinsToHrsMins(mins: number) { - const h = Math.floor(mins / 60); - const m = mins % 60; - const hs = h < 10 ? `0${h}` : h; - const ms = m < 10 ? `0${m}` : m; - return `${hs}:${ms}`; -} +TroubleshooterPage.getLayout = getLayout; +TroubleshooterPage.PageWrapper = PageWrapper; +export default TroubleshooterPage; diff --git a/apps/web/playwright/availability.e2e.ts b/apps/web/playwright/availability.e2e.ts index d065e3dbb5..bd07557603 100644 --- a/apps/web/playwright/availability.e2e.ts +++ b/apps/web/playwright/availability.e2e.ts @@ -40,6 +40,7 @@ test.describe("Availablity tests", () => { const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date); const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`; await page.goto(troubleshooterURL); + await page.waitForLoadState("networkidle"); await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 69906ee67e..c3a505de55 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2112,8 +2112,14 @@ "overlay_my_calendar":"Overlay my calendar", "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", + "troubleshooting":"Troubleshooting", + "calendars_were_checking_for_conflicts":"Calendars we’re checking for conflicts", + "availabilty_schedules":"Availability schedules", + "manage_calendars":"Manage calendars", + "manage_availability_schedules":"Manage availability schedules", "lock_timezone_toggle_on_booking_page": "Lock timezone on booking page", "description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.", + "install_calendar":"Install Calendar", "branded_subdomain": "Branded Subdomain", "branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com", "org_insights": "Organization-wide Insights", diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index 8ee9d44fb0..f8700ddcf9 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -33,6 +33,7 @@ const getCalendarsEvents = async ( const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type); if (!passedSelectedCalendars.length) return []; /** We extract external Ids so we don't cache too much */ + const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId); /** If we don't then we actually fetch external calendars (which can be very slow) */ performance.mark("eventBusyDatesStart"); @@ -51,7 +52,10 @@ const getCalendarsEvents = async ( "eventBusyDatesEnd" ); - return eventBusyDates.map((a) => ({ ...a, source: `${appId}` })); + return eventBusyDates.map((a) => ({ + ...a, + source: `${appId}`, + })); }); const awaitedResults = await Promise.all(results); performance.mark("getBusyCalendarTimesEnd"); diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx index c997e9a16d..8cb854ae09 100644 --- a/packages/features/calendars/weeklyview/components/event/Event.tsx +++ b/packages/features/calendars/weeklyview/components/event/Event.tsx @@ -1,6 +1,8 @@ import { cva } from "class-variance-authority"; import dayjs from "@calcom/dayjs"; +import classNames from "@calcom/lib/classNames"; +import { Tooltip } from "@calcom/ui"; import type { CalendarEvent } from "../../types/events"; @@ -13,7 +15,7 @@ type EventProps = { }; const eventClasses = cva( - "group flex h-full w-full flex-col overflow-y-auto rounded-[4px] px-[6px] py-1 text-xs font-semibold leading-5 ", + "group flex h-full w-full overflow-y-auto rounded-[6px] px-[6px] text-xs font-semibold leading-5 opacity-80", { variants: { status: { @@ -62,23 +64,41 @@ export function Event({ const Component = onEventClick ? "button" : "div"; return ( - onEventClick?.(event)} // Note this is not the button event. It is the calendar event. - className={eventClasses({ - status: options?.status, - disabled, - selected, - borderColor, - })} - style={styles}> -
- {event.title} -
- {eventDuration > 30 && ( -

- {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} -

- )} -
+ + onEventClick?.(event)} // Note this is not the button event. It is the calendar event. + className={classNames( + eventClasses({ + status: options?.status, + disabled, + selected, + borderColor, + }), + eventDuration > 30 && "flex-col py-1", + options?.className + )} + style={styles}> +
+ {event.title} + {eventDuration <= 30 && !event.options?.hideTime && ( +

+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} +

+ )} +
+ {eventDuration > 30 && !event.options?.hideTime && ( +

+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} +

+ )} + {eventDuration > 45 && event.description && ( +

{event.description}

+ )} +
+
); } diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx index 57cbb47e8c..ed6af0e763 100644 --- a/packages/features/calendars/weeklyview/components/event/EventList.tsx +++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; @@ -19,6 +20,14 @@ export function EventList({ day }: Props) { shallow ); + // Use a ref so we dont trigger a re-render + const longestRef = useRef<{ + start: Date; + end: Date; + duration: number; + idx: number; + } | null>(null); + return ( <> {events @@ -41,47 +50,59 @@ export function EventList({ day }: Props) { const nextEvent = eventsArray[idx + 1]; const prevEvent = eventsArray[idx - 1]; - // Check for overlapping events since this is sorted it should just work. - if (nextEvent) { - const nextEventStart = dayjs(nextEvent.start); - const nextEventEnd = dayjs(nextEvent.end); - // check if next event starts before this event ends - if (nextEventStart.isBefore(eventEnd)) { - // figure out which event has the longest duration - const nextEventDuration = nextEventEnd.diff(nextEventStart, "minutes"); - if (nextEventDuration > eventDuration) { + if (!longestRef.current) { + longestRef.current = { + idx, + start: eventStart.toDate(), + end: eventEnd.toDate(), + duration: eventDuration, + }; + } else if ( + eventDuration > longestRef.current.duration && + eventStart.isBetween(longestRef.current.start, longestRef.current.end) + ) { + longestRef.current = { + idx, + start: eventStart.toDate(), + end: eventEnd.toDate(), + duration: eventDuration, + }; + } + // By default longest event doesnt have any styles applied + if (longestRef.current.idx !== idx) { + if (nextEvent) { + // If we have a next event + const nextStart = dayjs(nextEvent.start); + // If the next event is inbetween the longest start and end make 65% width + if (nextStart.isBetween(longestRef.current.start, longestRef.current.end)) { zIndex = 65; - marginLeft = "auto"; - // 8 looks like a really random number but we need to take into account the bordersize on the event. - // Logically it should be 5% but this causes a bit of a overhang which we don't want. - right = 8; + right = 4; + width = width / 2; + + // If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have + // close start times + } else if (nextStart.isBetween(eventStart.add(-5, "minutes"), eventStart.add(5, "minutes"))) { + zIndex = 65; + marginLeft = "auto"; + right = 4; width = width / 2; } - } + } else if (prevEvent) { + const prevStart = dayjs(prevEvent.start); - if (nextEventStart.isSame(eventStart)) { - zIndex = 66; + // If the next event is inbetween the longest start and end make 65% width - marginLeft = "auto"; - right = 8; - width = width / 2; - } - } else if (prevEvent) { - const prevEventStart = dayjs(prevEvent.start); - const prevEventEnd = dayjs(prevEvent.end); - // check if next event starts before this event ends - if (prevEventEnd.isAfter(eventStart)) { - // figure out which event has the longest duration - const prevEventDuration = prevEventEnd.diff(prevEventStart, "minutes"); - if (prevEventDuration > eventDuration) { + if (prevStart.isBetween(longestRef.current.start, longestRef.current.end)) { zIndex = 65; marginLeft = "auto"; - right = 8; + right = 4; + // If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have + // close start times (Inverse of above ) + } else if (eventStart.isBetween(prevStart.add(5, "minutes"), prevStart.add(-5, "minutes"))) { + zIndex = 65; + right = 4; width = width / 2; - if (eventDuration >= 30) { - width = 80; - } } } } @@ -90,6 +111,7 @@ export function EventList({ day }: Props) {
{ const isRTL = () => { - const userLocale = navigator.language; - const userLanguage = new Intl.Locale(userLocale).language; + let userLanguage = "en"; // Default to 'en' if navigator is not defined + + if (typeof window !== "undefined" && typeof navigator !== "undefined") { + const userLocale = navigator.language; + userLanguage = new Intl.Locale(userLocale).language; + } return ["ar", "he", "fa", "ur"].includes(userLanguage); }; diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts index 9424eec9b8..af1b52358d 100644 --- a/packages/features/calendars/weeklyview/state/store.ts +++ b/packages/features/calendars/weeklyview/state/store.ts @@ -32,9 +32,7 @@ export const useCalendarStore = create((set) => ({ let events = state.events; if (state.sortEvents) { - events = state.events.sort( - (a, b) => dayjs(a.start).get("milliseconds") - dayjs(b.start).get("milliseconds") - ); + events = state.events.sort((a, b) => dayjs(a.start).valueOf() - dayjs(b.start).valueOf()); } const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index bade4cf0c4..493750ea8f 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -3,12 +3,16 @@ import type { BookingStatus } from "@calcom/prisma/enums"; export interface CalendarEvent { id: number; title: string; + description?: string; start: Date | string; // You can pass in a string from DB since we use dayjs for the dates. end: Date; source?: string; options?: { status?: BookingStatus; + hideTime?: boolean; allDay?: boolean; borderColor?: string; + className?: string; + "data-test-id"?: string; }; } diff --git a/packages/features/troubleshooter/Troubleshooter.tsx b/packages/features/troubleshooter/Troubleshooter.tsx new file mode 100644 index 0000000000..ad9457fdb1 --- /dev/null +++ b/packages/features/troubleshooter/Troubleshooter.tsx @@ -0,0 +1,70 @@ +import StickyBox from "react-sticky-box"; + +import classNames from "@calcom/lib/classNames"; +import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; + +import { LargeCalendar } from "./components/LargeCalendar"; +import { TroubleshooterHeader } from "./components/TroubleshooterHeader"; +import { TroubleshooterSidebar } from "./components/TroubleshooterSidebar"; +import { useInitalizeTroubleshooterStore } from "./store"; +import type { TroubleshooterProps } from "./types"; + +const extraDaysConfig = { + desktop: 7, + tablet: 4, +}; + +const TroubleshooterComponent = ({ month }: TroubleshooterProps) => { + const isMobile = useMediaQuery("(max-width: 768px)"); + const isTablet = useMediaQuery("(max-width: 1024px)"); + const extraDays = isTablet ? extraDaysConfig.tablet : extraDaysConfig.desktop; + + useInitalizeTroubleshooterStore({ + month: month, + }); + + return ( + <> +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ + ); +}; + +export const Troubleshooter = ({ month }: TroubleshooterProps) => { + return ; +}; diff --git a/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx b/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx new file mode 100644 index 0000000000..ee3196b731 --- /dev/null +++ b/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx @@ -0,0 +1,38 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge, Button, Switch } from "@calcom/ui"; + +import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer"; + +function AvailabiltyItem() { + const { t } = useLocale(); + return ( + + + Connected + +
+ }> +
+

{t("date_overrides")}

+ +
+ + ); +} + +export function AvailabiltySchedulesContainer() { + const { t } = useLocale(); + return ( +
+

{t("availabilty_schedules")}

+ + +
+ ); +} diff --git a/packages/features/troubleshooter/components/CalendarToggleContainer.tsx b/packages/features/troubleshooter/components/CalendarToggleContainer.tsx new file mode 100644 index 0000000000..4fceb7bf81 --- /dev/null +++ b/packages/features/troubleshooter/components/CalendarToggleContainer.tsx @@ -0,0 +1,121 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Badge, Button, Switch } from "@calcom/ui"; + +import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer"; + +const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"]; + +interface CalendarToggleItemProps { + title: string; + subtitle: string; + colorDot?: string; + status: "connected" | "not_found"; + calendars?: { + active?: boolean; + name?: string; + }[]; +} +function CalendarToggleItem(props: CalendarToggleItemProps) { + const badgeStatus = props.status === "connected" ? "green" : "orange"; + const badgeText = props.status === "connected" ? "Connected" : "Not found"; + return ( + +
+ + } + suffixSlot={ +
+ + {badgeText} + +
+ }> +
+ {props.calendars?.map((calendar) => { + return ; + })} +
+ + ); +} + +function EmptyCalendarToggleItem() { + const { t } = useLocale(); + + return ( + +
+ + } + suffixSlot={ +
+ + Not found + +
+ }> +
+ +
+ + ); +} + +export function CalendarToggleContainer() { + const { t } = useLocale(); + const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(); + + const hasConnectedCalendars = data && data?.connectedCalendars.length > 0; + + return ( +
+

{t("calendars_were_checking_for_conflicts")}

+ {hasConnectedCalendars && !isLoading ? ( + <> + {data.connectedCalendars.map((calendar) => { + const foundPrimary = calendar.calendars?.find((item) => item.primary); + // Will be used when getAvailbility is modified to use externalId instead of appId for source. + // const color = SELECTION_COLORS[idx] || "#000000"; + // // Add calendar to color map using externalId (what we use on the backend to determine source) + // addToColorMap(foundPrimary?.externalId, color); + return ( + { + return { + active: item.isSelected, + name: item.name, + }; + })} + /> + ); + })} + + + ) : ( + + )} +
+ ); +} diff --git a/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx b/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx new file mode 100644 index 0000000000..d25c70ada1 --- /dev/null +++ b/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx @@ -0,0 +1,38 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge } from "@calcom/ui"; + +import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer"; + +function ConnectedAppsItem() { + return ( + +
+ + } + suffixSlot={ +
+ + Connected + +
+ } + /> + ); +} + +export function ConnectedAppsContainer() { + const { t } = useLocale(); + return ( +
+

{t("other_apps")}

+
+ + +
+
+ ); +} diff --git a/packages/features/troubleshooter/components/EventScheduleItem.tsx b/packages/features/troubleshooter/components/EventScheduleItem.tsx new file mode 100644 index 0000000000..5cd47b2ccb --- /dev/null +++ b/packages/features/troubleshooter/components/EventScheduleItem.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Badge, Label } from "@calcom/ui"; + +import { useTroubleshooterStore } from "../store"; +import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer"; + +export function EventScheduleItem() { + const { t } = useLocale(); + const selectedEventType = useTroubleshooterStore((state) => state.event); + + const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery( + { + eventSlug: selectedEventType?.slug as string, + }, + { + enabled: !!selectedEventType?.slug, + } + ); + + return ( +
+ + } + title={schedule?.name ?? "Loading"} + suffixSlot={ + schedule && ( + + + {t("edit")} + + + ) + } + /> +
+ ); +} diff --git a/packages/features/troubleshooter/components/EventTypeSelect.tsx b/packages/features/troubleshooter/components/EventTypeSelect.tsx new file mode 100644 index 0000000000..dac68067b7 --- /dev/null +++ b/packages/features/troubleshooter/components/EventTypeSelect.tsx @@ -0,0 +1,53 @@ +import { useMemo, useEffect } from "react"; + +import { trpc } from "@calcom/trpc"; +import { SelectField } from "@calcom/ui"; + +import { useTroubleshooterStore } from "../store"; + +export function EventTypeSelect() { + const { data: eventTypes, isLoading } = trpc.viewer.eventTypes.list.useQuery(); + const selectedEventType = useTroubleshooterStore((state) => state.event); + const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent); + + // const selectedEventQueryParam = getQueryParam("eventType"); + + const options = useMemo(() => { + if (!eventTypes) return []; + return eventTypes.map((e) => ({ + label: e.title, + value: e.slug, + id: e.id, + duration: e.length, + })); + }, [eventTypes]); + + useEffect(() => { + if (!selectedEventType && eventTypes && eventTypes[0]) { + const { id, slug, length } = eventTypes[0]; + setSelectedEventType({ + id, + slug, + duration: length, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventTypes]); + + return ( + option.value === selectedEventType?.slug) || options[0]} + onChange={(option) => { + if (!option) return; + setSelectedEventType({ + slug: option.value, + id: option.id, + duration: option.duration, + }); + }} + /> + ); +} diff --git a/packages/features/troubleshooter/components/LargeCalendar.tsx b/packages/features/troubleshooter/components/LargeCalendar.tsx new file mode 100644 index 0000000000..7884e38e18 --- /dev/null +++ b/packages/features/troubleshooter/components/LargeCalendar.tsx @@ -0,0 +1,142 @@ +import { useSession } from "next-auth/react"; +import { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; + +import { useTimePreferences } from "../../bookings/lib/timePreferences"; +import { useSchedule } from "../../schedules/lib/use-schedule"; +import { useTroubleshooterStore } from "../store"; + +export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { + const { timezone } = useTimePreferences(); + const selectedDate = useTroubleshooterStore((state) => state.selectedDate); + const event = useTroubleshooterStore((state) => state.event); + const calendarToColorMap = useTroubleshooterStore((state) => state.calendarToColorMap); + const { data: session } = useSession(); + const startDate = selectedDate ? dayjs(selectedDate) : dayjs(); + + const { data: busyEvents } = trpc.viewer.availability.user.useQuery( + { + username: session?.user?.username || "", + dateFrom: startDate.startOf("day").utc().format(), + dateTo: startDate + .endOf("day") + .add(extraDays - 1, "day") + .utc() + .format(), + withSource: true, + }, + { + enabled: !!session?.user?.username, + } + ); + + const { data: schedule } = useSchedule({ + username: session?.user.username || "", + eventSlug: event?.slug, + eventId: event?.id, + timezone, + month: startDate.format("YYYY-MM"), + }); + + const endDate = dayjs(startDate) + .add(extraDays - 1, "day") + .toDate(); + + const availableSlots = useMemo(() => { + const availableTimeslots: CalendarAvailableTimeslots = {}; + if (!schedule) return availableTimeslots; + if (!schedule?.slots) return availableTimeslots; + + for (const day in schedule.slots) { + availableTimeslots[day] = schedule.slots[day].map((slot) => ({ + start: dayjs(slot.time).toDate(), + end: dayjs(slot.time) + .add(event?.duration ?? 30, "minutes") + .toDate(), + })); + } + + return availableTimeslots; + }, [schedule, event]); + + const events = useMemo(() => { + if (!busyEvents?.busy) return []; + + // TODO: Add buffer times in here as well just requires a bit of logic for fetching event type and then adding buffer time + // start: dayjs(startTime) + // .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") + // .toDate(), + // end: dayjs(endTime) + // .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute") + // .toDate(), + + const calendarEvents = busyEvents?.busy.map((event, idx) => { + return { + id: idx, + title: event.title ?? `Busy`, + start: new Date(event.start), + end: new Date(event.end), + options: { + borderColor: + event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : "black", + status: BookingStatus.ACCEPTED, + "data-test-id": "troubleshooter-busy-event", + }, + }; + }); + + if (busyEvents.dateOverrides) { + busyEvents.dateOverrides.forEach((dateOverride) => { + const dateOverrideStart = dayjs(dateOverride.start); + const dateOverrideEnd = dayjs(dateOverride.end); + + if (!dateOverrideStart.isSame(dateOverrideEnd)) { + return; + } + + const dayOfWeekNum = dateOverrideStart.day(); + + const workingHoursForDay = busyEvents.workingHours.find((workingHours) => + workingHours.days.includes(dayOfWeekNum) + ); + + if (!workingHoursForDay) return; + + calendarEvents.push({ + id: calendarEvents.length, + title: "Date Override", + start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(), + end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(), + options: { + borderColor: "black", + status: BookingStatus.ACCEPTED, + "data-test-id": "troubleshooter-busy-time", + }, + }); + }); + } + return calendarEvents; + }, [busyEvents, calendarToColorMap]); + + return ( +
+ +
+ ); +}; diff --git a/packages/features/troubleshooter/components/TroubleshooterHeader.tsx b/packages/features/troubleshooter/components/TroubleshooterHeader.tsx new file mode 100644 index 0000000000..90e6318184 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterHeader.tsx @@ -0,0 +1,80 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, ButtonGroup } from "@calcom/ui"; + +import { useTroubleshooterStore } from "../store"; + +export function TroubleshooterHeader({ extraDays, isMobile }: { extraDays: number; isMobile: boolean }) { + const { t, i18n } = useLocale(); + const selectedDateString = useTroubleshooterStore((state) => state.selectedDate); + const setSelectedDate = useTroubleshooterStore((state) => state.setSelectedDate); + const addToSelectedDate = useTroubleshooterStore((state) => state.addToSelectedDate); + const selectedDate = selectedDateString ? dayjs(selectedDateString) : dayjs(); + const today = dayjs(); + const selectedDateMin3DaysDifference = useMemo(() => { + const diff = today.diff(selectedDate, "days"); + return diff > 3 || diff < -3; + }, [today, selectedDate]); + + if (isMobile) return null; + + const endDate = selectedDate.add(extraDays - 1, "days"); + + const isSameMonth = () => { + return selectedDate.format("MMM") === endDate.format("MMM"); + }; + + const isSameYear = () => { + return selectedDate.format("YYYY") === endDate.format("YYYY"); + }; + const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" }); + const FormattedSelectedDateRange = () => { + return ( +

+ {formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")} + {!isSameYear() && , {selectedDate.format("YYYY")} }-{" "} + {!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "} + + {isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")} + +

+ ); + }; + + return ( +
+
+ + + + )} + +
+
+ ); +} diff --git a/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx new file mode 100644 index 0000000000..0d5bb956a3 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx @@ -0,0 +1,42 @@ +import type { PropsWithChildren } from "react"; + +import classNames from "@calcom/lib/classNames"; + +interface TroubleshooterListItemContainerProps { + title: string; + subtitle?: string; + suffixSlot?: React.ReactNode; + prefixSlot?: React.ReactNode; + className?: string; +} + +export function TroubleshooterListItemHeader({ + prefixSlot, + title, + subtitle, + suffixSlot, + className, +}: TroubleshooterListItemContainerProps) { + return ( +
+ {prefixSlot} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {suffixSlot} +
+ ); +} + +export function TroubleshooterListItemContainer({ + children, + ...rest +}: PropsWithChildren) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx new file mode 100644 index 0000000000..101656db27 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx @@ -0,0 +1,39 @@ +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Skeleton } from "@calcom/ui"; + +import { CalendarToggleContainer } from "./CalendarToggleContainer"; +import { EventScheduleItem } from "./EventScheduleItem"; +import { EventTypeSelect } from "./EventTypeSelect"; + +const BackButtonInSidebar = ({ name }: { name: string }) => { + return ( + + + + {name} + + + ); +}; + +export const TroubleshooterSidebar = () => { + const { t } = useLocale(); + + return ( +
+ + + + +
+ ); +}; diff --git a/packages/features/troubleshooter/layout.tsx b/packages/features/troubleshooter/layout.tsx new file mode 100644 index 0000000000..fdcc968cd4 --- /dev/null +++ b/packages/features/troubleshooter/layout.tsx @@ -0,0 +1,23 @@ +import type { ComponentProps } from "react"; +import React, { Suspense } from "react"; + +import Shell from "@calcom/features/shell/Shell"; +import { ErrorBoundary } from "@calcom/ui"; +import { Loader } from "@calcom/ui/components/icon"; + +export default function TroubleshooterLayout({ + children, + ...rest +}: { children: React.ReactNode } & ComponentProps) { + return ( + }> +
+ + }>{children} + +
+
+ ); +} + +export const getLayout = (page: React.ReactElement) => {page}; diff --git a/packages/features/troubleshooter/store.ts b/packages/features/troubleshooter/store.ts new file mode 100644 index 0000000000..4d5daa2601 --- /dev/null +++ b/packages/features/troubleshooter/store.ts @@ -0,0 +1,110 @@ +import { useEffect } from "react"; +import { create } from "zustand"; + +import dayjs from "@calcom/dayjs"; + +import { updateQueryParam, getQueryParam, removeQueryParam } from "../bookings/Booker/utils/query-param"; + +/** + * Arguments passed into store initializer, containing + * the event data. + */ +type StoreInitializeType = { + month: string | null; +}; + +type EventType = { + id: number; + slug: string; + duration: number; +}; + +export type TroubleshooterStore = { + event: EventType | null; + setEvent: (eventSlug: EventType) => void; + month: string | null; + setMonth: (month: string | null) => void; + selectedDate: string | null; + setSelectedDate: (date: string | null) => void; + addToSelectedDate: (days: number) => void; + initialize: (data: StoreInitializeType) => void; + calendarToColorMap: Record; + addToCalendarToColorMap: (calendarId: string | undefined, color: string) => void; +}; + +/** + * The booker store contains the data of the component's + * current state. This data can be reused within child components + * by importing this hook. + * + * See comments in interface above for more information on it's specific values. + */ +export const useTroubleshooterStore = create((set, get) => ({ + selectedDate: getQueryParam("date") || null, + setSelectedDate: (selectedDate: string | null) => { + // unset selected date + if (!selectedDate) { + removeQueryParam("date"); + return; + } + + const currentSelection = dayjs(get().selectedDate); + const newSelection = dayjs(selectedDate); + set({ selectedDate }); + updateQueryParam("date", selectedDate ?? ""); + + // Setting month make sure small calendar in fullscreen layouts also updates. + if (newSelection.month() !== currentSelection.month()) { + set({ month: newSelection.format("YYYY-MM") }); + updateQueryParam("month", newSelection.format("YYYY-MM")); + } + }, + addToSelectedDate: (days: number) => { + const selectedDate = get().selectedDate; + const currentSelection = selectedDate ? dayjs(get().selectedDate) : dayjs(); + const newSelection = currentSelection.add(days, "day"); + const newSelectionFormatted = newSelection.format("YYYY-MM-DD"); + + if (newSelection.month() !== currentSelection.month()) { + set({ month: newSelection.format("YYYY-MM") }); + updateQueryParam("month", newSelection.format("YYYY-MM")); + } + + set({ selectedDate: newSelectionFormatted }); + updateQueryParam("date", newSelectionFormatted); + }, + event: null, + setEvent: (event: EventType) => { + set({ event }); + updateQueryParam("eventType", event.slug ?? ""); + }, + month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"), + setMonth: (month: string | null) => { + set({ month }); + updateQueryParam("month", month ?? ""); + get().setSelectedDate(null); + }, + initialize: ({ month }: StoreInitializeType) => { + if (month) { + set({ month }); + updateQueryParam("month", month); + } + //removeQueryParam("layout"); + }, + calendarToColorMap: {}, + addToCalendarToColorMap: (calendarId: string | undefined, color: string) => { + if (!calendarId) return; + const calendarToColorMap = get().calendarToColorMap; + calendarToColorMap[calendarId] = color; + set({ calendarToColorMap }); + }, +})); + +export const useInitalizeTroubleshooterStore = ({ month }: StoreInitializeType) => { + const initializeStore = useTroubleshooterStore((state) => state.initialize); + useEffect(() => { + initializeStore({ + month, + }); + }, [initializeStore, month]); +}; diff --git a/packages/features/troubleshooter/types.ts b/packages/features/troubleshooter/types.ts new file mode 100644 index 0000000000..a1029e077b --- /dev/null +++ b/packages/features/troubleshooter/types.ts @@ -0,0 +1,13 @@ +export interface TroubleshooterProps { + /** + * If month is NOT set as a prop on the component, we expect a query parameter + * called `month` to be present on the url. If that is missing, the component will + * default to the current month. + * @note In case you're using a client side router, please pass the value in as a prop, + * since the component will leverage window.location, which might not have the query param yet. + * @format YYYY-MM. + * @optional + */ + month: string | null; + selectedDate?: Date; +} diff --git a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx index b886f81a64..f6e6fde73b 100644 --- a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx @@ -4,6 +4,7 @@ import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZScheduleDuplicateSchema } from "./duplicate.schema"; import { ZGetInputSchema } from "./get.schema"; +import { ZGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema"; import { ZGetByUserIdInputSchema } from "./getScheduleByUserId.schema"; import { ZUpdateInputSchema } from "./update.schema"; @@ -14,6 +15,7 @@ type ScheduleRouterHandlerCache = { update?: typeof import("./update.handler").updateHandler; duplicate?: typeof import("./duplicate.handler").duplicateHandler; getScheduleByUserId?: typeof import("./getScheduleByUserId.handler").getScheduleByUserIdHandler; + getScheduleByEventSlug?: typeof import("./getScheduleByEventTypeSlug.handler").getScheduleByEventSlugHandler; }; const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {}; @@ -118,4 +120,21 @@ export const scheduleRouter = router({ input, }); }), + getScheduleByEventSlug: authedProcedure.input(ZGetByEventSlugInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) { + UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug = await import( + "./getScheduleByEventTypeSlug.handler" + ).then((mod) => mod.getScheduleByEventSlugHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts new file mode 100644 index 0000000000..50457eff3a --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts @@ -0,0 +1,69 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import { getHandler } from "./get.handler"; +import type { TGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetByEventSlugInputSchema; +}; + +const EMPTY_SCHEDULE = [[], [], [], [], [], [], []]; + +export const getScheduleByEventSlugHandler = async ({ ctx, input }: GetOptions) => { + const foundScheduleForSlug = await ctx.prisma.eventType.findFirst({ + where: { + slug: input.eventSlug, + userId: ctx.user.id, + }, + select: { + scheduleId: true, + }, + }); + + try { + // This looks kinda weird that we throw straight in the catch - its so that we can return a default schedule if the user has not completed onboarding @shiraz will loveme for this + if (!foundScheduleForSlug?.scheduleId) { + const foundUserDefaultId = await ctx.prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + defaultScheduleId: true, + }, + }); + + if (foundUserDefaultId?.defaultScheduleId) { + return await getHandler({ + ctx, + input: { + scheduleId: foundUserDefaultId?.defaultScheduleId, + }, + }); + } + + throw new Error("NOT_FOUND"); + } + return await getHandler({ + ctx, + input: { + scheduleId: foundScheduleForSlug?.scheduleId, + }, + }); + } catch (e) { + console.log(e); + return { + id: -1, + name: "No schedules found", + availability: EMPTY_SCHEDULE, + dateOverrides: [], + timeZone: ctx.user.timeZone || "Europe/London", + workingHours: [], + isDefault: true, + }; + } +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts new file mode 100644 index 0000000000..b65f1b5e37 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetByEventSlugInputSchema = z.object({ + eventSlug: z.string(), +}); + +export type TGetByEventSlugInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts index d45c2d85c0..decfe5ca66 100644 --- a/packages/trpc/server/routers/viewer/availability/user.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -1,12 +1,15 @@ import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import type { TrpcSessionUser } from "../../../trpc"; import type { TUserInputSchema } from "./user.schema"; type UserOptions = { - ctx: Record; + ctx: { + user: NonNullable; + }; input: TUserInputSchema; }; export const userHandler = async ({ input }: UserOptions) => { - return getUserAvailability(input); + return getUserAvailability(input, undefined); }; From c55b36f2359fa2e4d4e8406eef607812aff48b7c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 20 Nov 2023 18:01:50 +0530 Subject: [PATCH 04/35] fix: Members count when `team` slug is same as `org` slug (#12124) --- packages/lib/server/queries/teams/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 8f39aa2c8c..7ef45e69c2 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -44,6 +44,7 @@ export async function getTeamWithMembers(args: { team: { select: { slug: true, + id: true, }, }, }, @@ -153,7 +154,7 @@ export async function getTeamWithMembers(args: { disableImpersonation: m.disableImpersonation, subteams: orgSlug ? m.user.teams - .filter((membership) => membership.team.slug !== orgSlug) + .filter((membership) => membership.team.id !== teamOrOrg.id) .map((membership) => membership.team.slug) : null, avatar: `${WEBAPP_URL}/${m.user.username}/avatar.png`, From 4f26ca1a7b0e4fe7eceeaab503b223d73871c95e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 20 Nov 2023 12:49:38 +0000 Subject: [PATCH 05/35] feat: Base implementation of v2 of avatars (#12369) * feat: Base implementation of v2 of avatars * Make avatarUrl and logoUrl entirely optional * Made necessary backwards compat changes * fix: type errors * Fix: OG image * fix types * Consistency with other behaviour, ux tweak --------- Co-authored-by: Peer Richelsen --- apps/web/components/ui/avatar/UserAvatar.tsx | 4 +- apps/web/pages/[user].tsx | 7 +- apps/web/pages/api/avatar/[uuid].ts | 61 ++++++++++++++ .../web/pages/settings/my-account/profile.tsx | 24 +++--- apps/web/playwright/fixtures/cal.png | Bin 0 -> 43681 bytes .../playwright/settings/upload-avatar.e2e.ts | 56 +++++++++++++ .../test/handlers/requestReschedule.test.ts | 1 + .../settings/layouts/SettingsLayout.tsx | 3 +- packages/lib/getAvatarUrl.ts | 23 ++++-- packages/lib/test/builder.ts | 1 + .../migration.sql | 19 +++++ packages/prisma/schema.prisma | 16 ++++ .../server/middlewares/sessionMiddleware.ts | 1 + .../routers/loggedInViewer/avatar.handler.ts | 11 ++- .../routers/loggedInViewer/me.handler.ts | 1 + .../loggedInViewer/updateProfile.handler.ts | 77 +++++++++--------- packages/ui/components/dialog/Dialog.tsx | 6 +- .../image-uploader/ImageUploader.tsx | 16 +++- 18 files changed, 258 insertions(+), 69 deletions(-) create mode 100644 apps/web/pages/api/avatar/[uuid].ts create mode 100644 apps/web/playwright/fixtures/cal.png create mode 100644 apps/web/playwright/settings/upload-avatar.e2e.ts create mode 100644 packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql diff --git a/apps/web/components/ui/avatar/UserAvatar.tsx b/apps/web/components/ui/avatar/UserAvatar.tsx index 63fa676676..a542fc3d9f 100644 --- a/apps/web/components/ui/avatar/UserAvatar.tsx +++ b/apps/web/components/ui/avatar/UserAvatar.tsx @@ -14,6 +14,6 @@ type UserAvatarProps = Omit, "alt" | "imageS * It is aware of the user's organization to correctly show the avatar from the correct URL */ export function UserAvatar(props: UserAvatarProps) { - const { user, previewSrc, ...rest } = props; - return ; + const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props; + return ; } diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 5a4f46eed0..46a0e5ffa5 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType[]; + users: Pick[]; themeBasis: string | null; markdownStrippedBio: string; safeBio: string; @@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps = async (cont metadata: true, brandColor: true, darkBrandColor: true, + avatarUrl: true, organizationId: true, organization: { select: { @@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps = async (cont image: user.avatar, theme: user.theme, brandColor: user.brandColor, + avatarUrl: user.avatarUrl, darkBrandColor: user.darkBrandColor, allowSEOIndexing: user.allowSEOIndexing ?? true, username: user.username, @@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps = async (cont name: user.name, username: user.username, bio: user.bio, + avatarUrl: user.avatarUrl, away: user.away, verified: user.verified, })), diff --git a/apps/web/pages/api/avatar/[uuid].ts b/apps/web/pages/api/avatar/[uuid].ts new file mode 100644 index 0000000000..2e8a1dfda3 --- /dev/null +++ b/apps/web/pages/api/avatar/[uuid].ts @@ -0,0 +1,61 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +const querySchema = z.object({ + uuid: z.string().transform((objectKey) => objectKey.split(".")[0]), +}); + +const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => { + const errors = error.errors.map((err) => ({ + path: err.path.join("."), + errorCode: `error.validation.${err.code}`, + })); + + res.status(400).json({ + message: "VALIDATION_ERROR", + errors, + }); +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const result = querySchema.safeParse(req.query); + if (!result.success) { + return handleValidationError(res, result.error); + } + + const { uuid: objectKey } = result.data; + + let img; + try { + const { data } = await prisma.avatar.findUniqueOrThrow({ + where: { + objectKey, + }, + select: { + data: true, + }, + }); + img = data; + } catch (e) { + // If anything goes wrong or avatar is not found, use default avatar + res.writeHead(302, { + Location: AVATAR_FALLBACK, + }); + + res.end(); + return; + } + + const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", ""); + const imageResp = Buffer.from(decoded, "base64"); + + res.writeHead(200, { + "Content-Type": "image/png", + "Content-Length": imageResp.length, + }); + + res.end(imageResp); +} diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 99e2418d80..6bd871bf7c 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -9,8 +9,8 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; -import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import turndown from "@calcom/lib/turndownService"; @@ -82,19 +82,12 @@ const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); const { update } = useSession(); + const { data: user, isLoading } = trpc.viewer.me.useQuery(); - const [fetchedImgSrc, setFetchedImgSrc] = useState(""); - - const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, { - onSuccess: async (userData) => { - try { - const res = await fetch(userData.avatar); - if (res.url) setFetchedImgSrc(res.url); - } catch (err) { - setFetchedImgSrc(""); - } - }, + const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, { + enabled: !isLoading && !user?.avatarUrl, }); + const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); @@ -226,7 +219,7 @@ const ProfileView = () => { const defaultValues = { username: user.username || "", - avatar: fetchedImgSrc || "", + avatar: getUserAvatarUrl(user), name: user.name || "", email: user.email || "", bio: user.bio || "", @@ -243,6 +236,7 @@ const ProfileView = () => { key={JSON.stringify(defaultValues)} defaultValues={defaultValues} isLoading={updateProfileMutation.isLoading} + isFallbackImg={!user.avatarUrl && !avatarData?.avatar} user={user} userOrganization={user.organization} onSubmit={(values) => { @@ -387,6 +381,7 @@ const ProfileForm = ({ onSubmit, extraField, isLoading = false, + isFallbackImg, user, userOrganization, }: { @@ -394,6 +389,7 @@ const ProfileForm = ({ onSubmit: (values: FormValues) => void; extraField?: React.ReactNode; isLoading: boolean; + isFallbackImg: boolean; user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { @@ -432,7 +428,7 @@ const ProfileForm = ({ control={formMethods.control} name="avatar" render={({ field: { value } }) => { - const showRemoveAvatarButton = !checkIfItFallbackImage(value); + const showRemoveAvatarButton = value === null ? false : !isFallbackImg; const organization = userOrganization && userOrganization.id ? { diff --git a/apps/web/playwright/fixtures/cal.png b/apps/web/playwright/fixtures/cal.png new file mode 100644 index 0000000000000000000000000000000000000000..8d300dfa0f407fd270f6fa5abfc191bab241eff2 GIT binary patch literal 43681 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
0lx7!|nArDn>Yw14p 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 OL_k#?l?o;6kpBYrs{ { + test("can upload an image", async ({ page, users }) => { + const user = await users.create({}); + await user.apiLogin(); + + await test.step("Can upload an initial picture", async () => { + await page.goto("/settings/my-account/profile"); + + await page.getByTestId("open-upload-avatar-dialog").click(); + + const [fileChooser] = await Promise.all([ + // It is important to call waitForEvent before click to set up waiting. + page.waitForEvent("filechooser"), + // Opens the file chooser. + page.getByTestId("open-upload-image-filechooser").click(), + ]); + + await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`); + + await page.getByTestId("upload-avatar").click(); + + await page.locator("input[name='name']").fill(user.email); + + await page.getByText("Update").click(); + await page.waitForSelector("text=Settings updated successfully"); + + const response = await prisma.avatar.findUniqueOrThrow({ + where: { + teamId_userId: { + userId: user.id, + teamId: 0, + }, + }, + }); + + // todo: remove this; ideally the organization-avatar is updated the moment + // 'Settings updated succesfully' is saved. + await page.waitForLoadState("networkidle"); + + await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey); + + const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, { + maxRedirects: 0, + }); + + await expect(urlResponse?.status()).toBe(200); + }); + }); +}); diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index 09e01ce308..b6ac6cfab8 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -268,6 +268,7 @@ function getTrpcHandlerData({ user: { ...getSampleUserInSession(), ...user, + avatarUrl: user.avatarUrl || null, } satisfies TrpcSessionUser, }, input: input, diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 95f217cb15..677c43f5e4 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -10,6 +10,7 @@ import Shell from "@calcom/features/shell/Shell"; import { classNames } from "@calcom/lib"; import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; @@ -145,7 +146,7 @@ const useTabs = () => { if (tab.href === "/settings/my-account") { tab.name = user?.name || "my_account"; tab.icon = undefined; - tab.avatar = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${session?.data?.user?.username}/avatar.png`; + tab.avatar = getUserAvatarUrl(user); } else if (tab.href === "/settings/organizations") { tab.name = orgBranding?.name || "organization"; tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`; diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts index 2c971be827..f1d11aba42 100644 --- a/packages/lib/getAvatarUrl.ts +++ b/packages/lib/getAvatarUrl.ts @@ -6,19 +6,28 @@ import type { User, Team } from "@calcom/prisma/client"; * Gives an organization aware avatar url for a user * It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed */ -export const getUserAvatarUrl = (user: Pick) => { - if (!user.username) return AVATAR_FALLBACK; +export const getUserAvatarUrl = ( + user: (Pick & { avatarUrl?: string | null }) | undefined +) => { + if (user?.avatarUrl) { + return user.avatarUrl; + } + if (!user?.username) return AVATAR_FALLBACK; // avatar.png automatically redirects to fallback avatar if user doesn't have one return `${WEBAPP_URL}/${user.username}/avatar.png${ user.organizationId ? `?orgId=${user.organizationId}` : "" }`; }; -export const getOrgAvatarUrl = (org: { - id: Team["id"]; - slug: Team["slug"]; - requestedSlug: string | null; -}) => { +export const getOrgAvatarUrl = ( + org: Pick & { + logoUrl?: string | null; + requestedSlug: string | null; + } +) => { + if (org.logoUrl) { + return org.logoUrl; + } const slug = org.slug ?? org.requestedSlug; return `${WEBAPP_URL}/org/${slug}/avatar.png`; }; diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index e9eb598659..4a2f79c891 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -191,6 +191,7 @@ export const buildUser = >(user?: T): UserPayload allowDynamicBooking: true, availability: [], avatar: "", + avatarUrl: "", away: false, backupCodes: null, bio: null, diff --git a/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql b/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql new file mode 100644 index 0000000000..2d539596fe --- /dev/null +++ b/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "logoUrl" TEXT; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "avatarUrl" TEXT; + +-- CreateTable +CREATE TABLE "avatars" ( + "teamId" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER NOT NULL DEFAULT 0, + "data" TEXT NOT NULL, + "objectKey" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "avatars_objectKey_key" ON "avatars"("objectKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "avatars_teamId_userId_key" ON "avatars"("teamId", "userId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3130a9d423..8cc61111fa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -192,6 +192,7 @@ model User { password String? bio String? avatar String? + avatarUrl String? timeZone String @default("Europe/London") weekStart String @default("Sunday") // DEPRECATED - TO BE REMOVED @@ -279,6 +280,7 @@ model Team { /// @zod.min(1) slug String? logo String? + logoUrl String? appLogo String? appIconLogo String? bio String? @@ -1011,3 +1013,17 @@ model TempOrgRedirect { @@unique([from, type, fromOrgId]) } + +model Avatar { + // e.g. NULL(0), organization ID or team logo + teamId Int @default(0) + // Avatar, NULL(0) if team logo + userId Int @default(0) + // base64 string + data String + // different every time to pop the cache. + objectKey String @unique + + @@unique([teamId, userId]) + @@map(name: "avatars") +} diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 46b55b6450..3eae4eb1a8 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -30,6 +30,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { + const data = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + avatar: true, + }, + }); return { - avatar: ctx.user.avatar, + avatar: data?.avatar, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 3b53cfa0c6..4c4b72f648 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -25,6 +25,7 @@ export const meHandler = async ({ ctx }: MeOptions) => { timeFormat: user.timeFormat, timeZone: user.timeZone, avatar: getUserAvatarUrl(user), + avatarUrl: user.avatarUrl, createdDate: user.createdDate, trialEndsAt: user.trialEndsAt, defaultScheduleId: user.defaultScheduleId, diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 4231d45dc2..c790a446ad 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; import type { GetServerSidePropsContext, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; @@ -31,12 +32,35 @@ type UpdateProfileOptions = { input: TUpdateProfileInputSchema; }; +const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => { + const objectKey = uuidv4(); + + await prisma.avatar.upsert({ + where: { + teamId_userId: { + teamId: 0, + userId, + }, + }, + create: { + userId: userId, + data, + objectKey, + }, + update: { + data, + objectKey, + }, + }); + + return `/api/avatar/${objectKey}.png`; +}; + export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => { const { user } = ctx; const userMetadata = handleUserMetadata({ ctx, input }); const data: Prisma.UserUpdateInput = { ...input, - avatar: input.avatar ? await getAvatarToSet(input.avatar) : null, metadata: userMetadata, }; @@ -114,6 +138,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) // when the email changes, the user needs to sign in again. signOutUser = true; } + // don't do anything if avatar is undefined. + if (typeof input.avatar !== "undefined") { + data.avatarUrl = input.avatar + ? await uploadAvatar({ + avatar: await resizeBase64Image(input.avatar), + userId: user.id, + }) + : null; + } const updatedUser = await prisma.user.update({ where: { @@ -129,6 +162,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) metadata: true, name: true, createdDate: true, + avatarUrl: true, locale: true, schedules: { select: { @@ -186,28 +220,11 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) }, }); } - // Revalidate booking pages - // Disabled because the booking pages are currently not using getStaticProps - /*const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - team: null, - }, - select: { - id: true, - slug: true, - }, - }); - // waiting for this isn't needed - Promise.all( - eventTypes.map((eventType) => res?.revalidate(`/new-booker/${ctx.user.username}/${eventType.slug}`)) - ) - .then(() => console.info("Booking pages revalidated")) - .catch((e) => console.error(e)); - }*/ - return { ...input, signOutUser, passwordReset }; + + // don't return avatar, we don't need it anymore. + delete input.avatar; + + return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl }; }; const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => { @@ -230,17 +247,3 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => { // Required so we don't override and delete saved values return { ...userMetadata, ...cleanMetadata }; }; - -async function getAvatarToSet(avatar: string | null | undefined) { - if (avatar === null || avatar === undefined) { - return avatar; - } - - if (!avatar.startsWith("data:image")) { - // Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint - log.warn("Non Base64 avatar, ignored it", { avatar }); - // `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here. - return undefined; - } - return await resizeBase64Image(avatar); -} diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index 06971f9745..836367155a 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -177,6 +177,7 @@ export const DialogTrigger = DialogPrimitive.Trigger; export function DialogClose( props: { + "data-testid"?: string; dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>; children?: ReactNode; onClick?: (e: React.MouseEvent) => void; @@ -188,7 +189,10 @@ export function DialogClose( return ( {/* This will require the i18n string passed in */} - diff --git a/packages/ui/components/image-uploader/ImageUploader.tsx b/packages/ui/components/image-uploader/ImageUploader.tsx index 1f2c5089e5..6f77756f07 100644 --- a/packages/ui/components/image-uploader/ImageUploader.tsx +++ b/packages/ui/components/image-uploader/ImageUploader.tsx @@ -170,7 +170,11 @@ export default function ImageUploader({ } }}> - @@ -190,7 +194,9 @@ export default function ImageUploader({
)} {result && } - + {t("community_support")}{" "} + + ); } From ed2ce005c9587c2081d2fac01f4d417bece84f55 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 20 Nov 2023 16:42:20 +0000 Subject: [PATCH 12/35] New Crowdin translations by Github Action --- apps/web/public/static/locales/ar/common.json | 1 + apps/web/public/static/locales/cs/common.json | 1 + apps/web/public/static/locales/de/common.json | 1 + apps/web/public/static/locales/es/common.json | 1 + apps/web/public/static/locales/fr/common.json | 1 + apps/web/public/static/locales/he/common.json | 1 + apps/web/public/static/locales/it/common.json | 1 + apps/web/public/static/locales/ja/common.json | 1 + apps/web/public/static/locales/ko/common.json | 1 + apps/web/public/static/locales/nl/common.json | 1 + apps/web/public/static/locales/pl/common.json | 1 + apps/web/public/static/locales/pt-BR/common.json | 1 + apps/web/public/static/locales/pt/common.json | 1 + apps/web/public/static/locales/ro/common.json | 1 + apps/web/public/static/locales/ru/common.json | 1 + apps/web/public/static/locales/sr/common.json | 1 + apps/web/public/static/locales/sv/common.json | 1 + apps/web/public/static/locales/tr/common.json | 1 + apps/web/public/static/locales/uk/common.json | 1 + apps/web/public/static/locales/vi/common.json | 1 + apps/web/public/static/locales/zh-CN/common.json | 1 + apps/web/public/static/locales/zh-TW/common.json | 1 + 22 files changed, 22 insertions(+) diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index a156291271..438753aad1 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "مستندات المطور", "get_in_touch": "تواصل معنا", "contact_support": "الاتصال بالدعم", + "community_support": "الدعم المجتمعي", "feedback": "الملاحظات", "submitted_feedback": "نشكرك على ملاحظاتك!", "feedback_error": "حدث خطأ عند إرسال الملاحظات", diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index 6f8993cb9f..3090110820 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Dokumentace vývojáře", "get_in_touch": "Kontaktujte nás", "contact_support": "Kontaktujte podporu", + "community_support": "Podpora komunity", "feedback": "Zpětná vazba", "submitted_feedback": "Děkujeme za vaši zpětnou vazbu!", "feedback_error": "Chyba při odesílání zpětné vazby", diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 1ab3564dff..a9da73eb13 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Entwickler-Dokumentation", "get_in_touch": "Kontakt aufnehmen", "contact_support": "Support kontaktieren", + "community_support": "Community Support", "feedback": "Feedback", "submitted_feedback": "Vielen Dank für Ihr Feedback!", "feedback_error": "Fehler beim Senden des Feedbacks", diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 271b5e7c18..74f2701815 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentación del desarrollador", "get_in_touch": "Póngase en contacto", "contact_support": "Contactar con Soporte", + "community_support": "Soporte comunitario", "feedback": "Comentarios", "submitted_feedback": "¡Gracias por sus comentarios!", "feedback_error": "Error al enviar comentarios", diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index e0bab7ee71..49dc72ca82 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentation pour développeurs", "get_in_touch": "Contactez-nous", "contact_support": "Contacter l'assistance", + "community_support": "Aide communautaire", "feedback": "Commentaires", "submitted_feedback": "Merci pour vos commentaires !", "feedback_error": "Erreur lors de l'envoi du commentaire", diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index fdb11fce61..8940a363c2 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "מסמכי מפתחים", "get_in_touch": "יצירת קשר", "contact_support": "פנייה לתמיכה", + "community_support": "תמיכת קהילה", "feedback": "משוב", "submitted_feedback": "תודה על המשוב!", "feedback_error": "שגיאה בעת שליחת משוב", diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index 39c8a82614..bd4b918ace 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentazione sviluppatore", "get_in_touch": "Contattaci", "contact_support": "Contatta il supporto", + "community_support": "Supporto della community", "feedback": "Feedback", "submitted_feedback": "Grazie per il tuo feedback!", "feedback_error": "Errore durante l'invio del feedback", diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index 09b9705e4c..60af134a30 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "開発者向けドキュメント", "get_in_touch": "お問い合わせ", "contact_support": "サポートに連絡", + "community_support": "コミュニティサポート", "feedback": "フィードバック", "submitted_feedback": "フィードバックをありがとうございます!", "feedback_error": "フィードバックの送信エラー", diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index 4bae904c16..09688e49f9 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "개발자 문서", "get_in_touch": "연락하기", "contact_support": "지원 문의", + "community_support": "커뮤니티 지원", "feedback": "피드백", "submitted_feedback": "피드백을 주셔서 감사합니다!", "feedback_error": "피드백을 보내는 중 오류 발생", diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index 7aed827cc4..dddbfd107b 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Ontwikkelaarsdocumentatie", "get_in_touch": "Neem contact op", "contact_support": "Neem contact op met de ondersteuning", + "community_support": "Ondersteuning door de community", "feedback": "Feedback", "submitted_feedback": "Bedankt voor uw feedback!", "feedback_error": "Fout bij verzenden van feedback", diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index f228db75b5..833b51d4aa 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Dokumentacja dla programistów", "get_in_touch": "Kontakt", "contact_support": "Skontaktuj się z pomocą techniczną", + "community_support": "Wsparcie społeczności", "feedback": "Opinia", "submitted_feedback": "Dziękujemy za opinię!", "feedback_error": "Podczas wysyłania opinii wystąpił błąd", diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index f4721862d2..6d2372e417 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentação do desenvolvedor", "get_in_touch": "Entrar em contato", "contact_support": "Fale com o suporte", + "community_support": "Suporte da comunidade", "feedback": "Comentário", "submitted_feedback": "Agradecemos o seu comentário!", "feedback_error": "Erro ao enviar comentário", diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index f2b075e7fc..8c784c7006 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentação para programadores", "get_in_touch": "Entre em contacto", "contact_support": "Contacte o suporte", + "community_support": "Apoio da Comunidade", "feedback": "Feedback", "submitted_feedback": "Obrigado pelo seu feedback!", "feedback_error": "Erro ao enviar feedback", diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index f294bc25dd..9a5c4a1e07 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentație pentru programatori", "get_in_touch": "Contactați-ne", "contact_support": "Contactați echipa de asistență", + "community_support": "Asistență comunitate", "feedback": "Feedback", "submitted_feedback": "Vă mulțumim pentru feedback!", "feedback_error": "Eroare la trimiterea feedbackului", diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index 474d6744d9..6a4dc2f2b6 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Документация для разработчиков", "get_in_touch": "Связаться с нами", "contact_support": "Обратиться в службу поддержки", + "community_support": "Поддержка со стороны сообщества", "feedback": "Отзыв", "submitted_feedback": "Спасибо за отзыв!", "feedback_error": "Ошибка при отправке отзыва", diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index 9801220ae8..d209e645af 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Dokumentacija za razvojne programere", "get_in_touch": "Kontaktirajte nas", "contact_support": "Obratite se podršci", + "community_support": "Podrška zajednice", "feedback": "Povratne informacije", "submitted_feedback": "Hvala vam na povratnim informacijama!", "feedback_error": "Greška pri slanju povratnih informacija", diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index dddf3cf7f4..1dfe0438e3 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Utvecklardokumentation", "get_in_touch": "Kontakta oss", "contact_support": "Kontakta support", + "community_support": "Communitysupport", "feedback": "Feedback", "submitted_feedback": "Tack för din feedback!", "feedback_error": "Det gick inte att skicka feedback", diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index 51a402d4ed..48bcfa4819 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Geliştirici Belgeleri", "get_in_touch": "Bize ulaşın", "contact_support": "Destek ile iletişime geçin", + "community_support": "Topluluk Desteği", "feedback": "Geri bildirim", "submitted_feedback": "Geri bildiriminiz için teşekkür ederiz!", "feedback_error": "Geri bildirim gönderilirken bir hata oluştu", diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index c9420f5064..1fc67deab6 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Документація для розробників", "get_in_touch": "Наші контакти", "contact_support": "Служба підтримки", + "community_support": "Підтримка спільноти", "feedback": "Відгук", "submitted_feedback": "Дякуємо за відгук!", "feedback_error": "Не вдалося надіслати відгук", diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index 5b22f88916..b6470ef6b2 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Tài liệu nhà phát triển", "get_in_touch": "Liên lạc", "contact_support": "Liên hệ với bộ phận hỗ trợ", + "community_support": "Hỗ trợ cộng đồng", "feedback": "Phản hồi", "submitted_feedback": "Cám ơn bạn đã phản hồi!", "feedback_error": "Có lỗi khi gửi phản hồi", diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index ea01b7fe6d..14dc1b7d4e 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -1099,6 +1099,7 @@ "developer_documentation": "开发人员文档", "get_in_touch": "保持联系", "contact_support": "联系支持", + "community_support": "社区支持", "feedback": "反馈", "submitted_feedback": "感谢您的反馈!", "feedback_error": "发送反馈时出错", diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index 055f29449e..41a1af0db3 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "開發人員文件", "get_in_touch": "保持聯絡", "contact_support": "聯絡支援", + "community_support": "社群支援", "feedback": "回饋意見", "submitted_feedback": "感謝您的回饋意見!", "feedback_error": "傳送回饋意見時發生錯誤", From 00553e897bca9b8a63b6854a3121f775a94b2564 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:09:50 +0200 Subject: [PATCH 13/35] fix: alby payment could not be created (#12460) * fix: alby payment could not be created * fixup! fix: alby payment could not be created * fixup! fixup! fix: alby payment could not be created --- packages/app-store/alby/lib/PaymentService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app-store/alby/lib/PaymentService.ts b/packages/app-store/alby/lib/PaymentService.ts index 9974f1aa25..71e9c3e851 100644 --- a/packages/app-store/alby/lib/PaymentService.ts +++ b/packages/app-store/alby/lib/PaymentService.ts @@ -72,7 +72,10 @@ export class PaymentService implements IAbstractPaymentService { amount: payment.amount, externalId: invoice.paymentRequest, currency: payment.currency, - data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue, + data: Object.assign( + {}, + { invoice: { ...invoice, isPaid: await invoice.isPaid() } } + ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, success: false, @@ -84,7 +87,7 @@ export class PaymentService implements IAbstractPaymentService { } return paymentData; } catch (error) { - log.error("Alby: Payment could not be created", bookingId); + log.error("Alby: Payment could not be created", bookingId, JSON.stringify(error)); throw new Error(ErrorCode.PaymentCreationFailure); } } From 404bc0e4d6bb4066712ba21ebf7775b927117360 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 21 Nov 2023 12:13:50 +0000 Subject: [PATCH 14/35] New Crowdin translations by Github Action --- apps/web/public/static/locales/fr/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 49dc72ca82..0bf2d447f7 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentation pour développeurs", "get_in_touch": "Contactez-nous", "contact_support": "Contacter l'assistance", + "premium_support": "Assistance Premium", "community_support": "Aide communautaire", "feedback": "Commentaires", "submitted_feedback": "Merci pour vos commentaires !", From 85237c49851584f62eb56707bbe5358e1d88d5e2 Mon Sep 17 00:00:00 2001 From: Ujjwal Goyal <35370133+ujjwalgoyal19@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:26:59 +0530 Subject: [PATCH 15/35] fix: Date overrides UI bug depending on screen size (#12423) * Update DateOverrideInputDialog.tsx fix: Date overrides UI bug depending on screen size (calcom#12406) * chore: remove comment --------- Co-authored-by: madhurgoyal19 <35370133+madhurgoyal19@users.noreply.github.com> Co-authored-by: Udit Takkar --- .../schedules/components/DateOverrideInputDialog.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/features/schedules/components/DateOverrideInputDialog.tsx b/packages/features/schedules/components/DateOverrideInputDialog.tsx index 48612ff4ce..a628e81b0c 100644 --- a/packages/features/schedules/components/DateOverrideInputDialog.tsx +++ b/packages/features/schedules/components/DateOverrideInputDialog.tsx @@ -5,7 +5,6 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import type { WorkingHours } from "@calcom/types/schedule"; import { Dialog, @@ -210,19 +209,12 @@ const DateOverrideInputDialog = ({ onChange: (newValue: TimeRange[]) => void; value?: TimeRange[]; }) => { - const isMobile = useMediaQuery("(max-width: 768px)"); const [open, setOpen] = useState(false); - { - /* enableOverflow is used to allow overflow when there are too many overrides to show on mobile. - ref:- https://github.com/calcom/cal.com/pull/6215 - */ - } - const enableOverflow = isMobile; return ( {Trigger} - + Date: Tue, 21 Nov 2023 17:14:25 +0200 Subject: [PATCH 16/35] fix: better errors for googlecalendar integration (#12403) --- packages/app-store/googlecalendar/api/add.ts | 48 ++++++++++--------- .../app-store/googlecalendar/api/callback.ts | 17 ++++--- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/app-store/googlecalendar/api/add.ts b/packages/app-store/googlecalendar/api/add.ts index 3a32c968fa..7ed6fcf02d 100644 --- a/packages/app-store/googlecalendar/api/add.ts +++ b/packages/app-store/googlecalendar/api/add.ts @@ -2,6 +2,8 @@ import { google } from "googleapis"; import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; @@ -14,28 +16,30 @@ const scopes = [ let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") { - // Get token from Google Calendar API - const appKeys = await getAppKeysFromSlug("google-calendar"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); - const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + // Get token from Google Calendar API + const appKeys = await getAppKeysFromSlug("google-calendar"); + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); + const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); - const authUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", - scope: scopes, - // A refresh token is only returned the first time the user - // consents to providing access. For illustration purposes, - // setting the prompt to 'consent' will force this consent - // every time, forcing a refresh_token to be returned. - prompt: "consent", - state: encodeOAuthState(req), - }); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: scopes, + // A refresh token is only returned the first time the user + // consents to providing access. For illustration purposes, + // setting the prompt to 'consent' will force this consent + // every time, forcing a refresh_token to be returned. + prompt: "consent", + state: encodeOAuthState(req), + }); - res.status(200).json({ url: authUrl }); - } + res.status(200).json({ url: authUrl }); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index 2b3d2d90b0..3577e9b092 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; @@ -12,24 +14,23 @@ import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function getHandler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; const state = decodeOAuthState(req); if (typeof code !== "string") { - res.status(400).json({ message: "`code` must be a string" }); - return; + throw new HttpError({ statusCode: 400, message: "`code` must be a string" }); } if (!req.session?.user?.id) { - return res.status(401).json({ message: "You must be logged in to do this" }); + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); } const appKeys = await getAppKeysFromSlug("google-calendar"); if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; @@ -107,3 +108,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) getInstalledAppPath({ variant: "calendar", slug: "google-calendar" }) ); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); From 48dde246e92fd2ed94c84bf39389078ab9ec639a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 21 Nov 2023 22:33:01 +0530 Subject: [PATCH 17/35] test: Add more orgs tests (#12241) --- apps/web/pages/signup.tsx | 4 +- apps/web/playwright/fixtures/clipboard.ts | 34 +++++ apps/web/playwright/fixtures/users.ts | 20 ++- apps/web/playwright/lib/fixtures.ts | 9 ++ apps/web/playwright/lib/testUtils.ts | 11 ++ apps/web/playwright/organization/expects.ts | 28 ++++ .../organization/organization-creation.e2e.ts | 143 ++++++++++++++++++ .../organization-invitation.e2e.ts | 119 +++++++++++++++ package.json | 1 + .../organizations/pages/settings/members.tsx | 18 --- .../components/MemberInvitationModal.tsx | 12 +- .../components/UserTable/UserListTable.tsx | 7 +- .../viewer/organizations/create.handler.ts | 8 +- .../organizations/verifyCode.handler.ts | 6 +- yarn.lock | 12 ++ 15 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 apps/web/playwright/fixtures/clipboard.ts create mode 100644 apps/web/playwright/organization/expects.ts create mode 100644 apps/web/playwright/organization/organization-creation.e2e.ts create mode 100644 apps/web/playwright/organization/organization-invitation.e2e.ts diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 0b0cb0e5a8..041dcd943d 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -37,7 +37,7 @@ type SignupProps = inferSSRProps; const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { - const [emailUser, emailDomain] = email.split("@"); + const [emailUser, emailDomain = ""] = email.split("@"); const username = emailDomain === autoAcceptEmailDomain ? slugify(emailUser) @@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA methods.clearErrors("apiError"); } - if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { + if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { methods.setValue( "username", getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) diff --git a/apps/web/playwright/fixtures/clipboard.ts b/apps/web/playwright/fixtures/clipboard.ts new file mode 100644 index 0000000000..47cc92d95c --- /dev/null +++ b/apps/web/playwright/fixtures/clipboard.ts @@ -0,0 +1,34 @@ +import type { Page } from "@playwright/test"; + +declare global { + interface Window { + E2E_CLIPBOARD_VALUE?: string; + } +} + +export type Window = typeof window; +// creates the single server fixture +export const createClipboardFixture = (page: Page) => { + return { + reset: async () => { + await page.evaluate(() => { + delete window.E2E_CLIPBOARD_VALUE; + }); + }, + get: async () => { + return getClipboardValue({ page }); + }, + }; +}; + +function getClipboardValue({ page }: { page: Page }) { + return page.evaluate(() => { + return new Promise((resolve, reject) => { + setInterval(() => { + if (!window.E2E_CLIPBOARD_VALUE) return; + resolve(window.E2E_CLIPBOARD_VALUE); + }, 500); + setTimeout(() => reject(new Error("Timeout")), 1000); + }); + }); +} diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 0f07d18507..ae5fbfbec2 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -86,12 +86,14 @@ const createTeamAndAddUser = async ( user, isUnpublished, isOrg, + isOrgVerified, hasSubteam, organizationId, }: { - user: { id: number; username: string | null; role?: MembershipRole }; + user: { id: number; email: string; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; organizationId?: number | null; }, @@ -103,7 +105,14 @@ const createTeamAndAddUser = async ( }; data.metadata = { ...(isUnpublished ? { requestedSlug: slug } : {}), - ...(isOrg ? { isOrganization: true } : {}), + ...(isOrg + ? { + isOrganization: true, + isOrganizationVerified: !!isOrgVerified, + orgAutoAcceptEmail: user.email.split("@")[1], + isOrganizationConfigured: false, + } + : {}), }; data.slug = !isUnpublished ? slug : undefined; if (isOrg && hasSubteam) { @@ -145,6 +154,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn teamEventSlug?: string; teamEventLength?: number; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; isUnpublished?: true; } = {} @@ -292,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn if (scenario.hasTeam) { const team = await createTeamAndAddUser( { - user: { id: user.id, username: user.username, role: "OWNER" }, + user: { id: user.id, email: user.email, username: user.username, role: "OWNER" }, isUnpublished: scenario.isUnpublished, isOrg: scenario.isOrg, + isOrgVerified: scenario.isOrgVerified, hasSubteam: scenario.hasSubteam, organizationId: opts?.organizationId, }, @@ -410,6 +421,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { routingForms: user.routingForms, self, apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page), + /** + * @deprecated use apiLogin instead + */ login: async () => login({ ...(await self()), password: user.username }, store.page), logout: async () => { await page.goto("/auth/logout"); diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 2e54268db3..cf66ebb2f9 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -4,10 +4,12 @@ import type { API } from "mailhog"; import mailhog from "mailhog"; import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; +import { createClipboardFixture } from "../fixtures/clipboard"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createOrgsFixture } from "../fixtures/orgs"; import { createPaymentsFixture } from "../fixtures/payments"; @@ -28,6 +30,7 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; + clipboard: ReturnType; } declare global { @@ -85,6 +88,8 @@ export const test = base.extend({ const mailhogAPI = mailhog(); await use(mailhogAPI); } else { + //FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it + logger.warn("Mailhog is not enabled - Skipping Emails verification"); await use(undefined); } }, @@ -92,4 +97,8 @@ export const test = base.extend({ const bookingPage = createBookingPageFixture(page); await use(bookingPage); }, + clipboard: async ({ page }, use) => { + const clipboard = createClipboardFixture(page); + await use(clipboard); + }, }); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index b9cf3850d6..7038b656b1 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,11 +1,13 @@ import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { createHash } from "crypto"; import EventEmitter from "events"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; // eslint-disable-next-line no-restricted-imports import { noop } from "lodash"; import type { API, Messages } from "mailhog"; +import { totp } from "otplib"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees( }); return { user, eventType, booking }; } + +export function generateTotpCode(email: string) { + const secret = createHash("md5") + .update(email + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + totp.options = { step: 90 }; + return totp.generate(secret); +} diff --git a/apps/web/playwright/organization/expects.ts b/apps/web/playwright/organization/expects.ts new file mode 100644 index 0000000000..e5ba1a0e83 --- /dev/null +++ b/apps/web/playwright/organization/expects.ts @@ -0,0 +1,28 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; +// eslint-disable-next-line no-restricted-imports +import type { API, Messages } from "mailhog"; + +import { getEmailsReceivedByUser } from "../lib/testUtils"; + +export async function expectInvitationEmailToBeReceived( + page: Page, + emails: API | undefined, + userEmail: string, + subject: string, + returnLink?: string +) { + if (!emails) return null; + // We need to wait for the email to go through, otherwise it will fail + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(5000); + const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); + expect(receivedEmails?.total).toBe(1); + const [firstReceivedEmail] = (receivedEmails as Messages).items; + expect(firstReceivedEmail.subject).toBe(subject); + if (!returnLink) return; + const dom = new JSDOM(firstReceivedEmail.html); + const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); + return anchor?.getAttribute("href"); +} diff --git a/apps/web/playwright/organization/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts new file mode 100644 index 0000000000..19b3477026 --- /dev/null +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -0,0 +1,143 @@ +import { expect } from "@playwright/test"; +import path from "path"; + +import { test } from "../lib/fixtures"; +import { generateTotpCode } from "../lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.afterAll(({ users, emails }) => { + users.deleteAll(); + emails?.deleteAll(); +}); + +function capitalize(text: string) { + if (!text) { + return text; + } + return text.charAt(0).toUpperCase() + text.slice(1); +} + +test.describe("Organization", () => { + test("should be able to create an organization and complete onboarding", async ({ + page, + users, + emails, + }) => { + const orgOwner = await users.create(); + const orgDomain = `${orgOwner.username}-org`; + const orgName = capitalize(`${orgOwner.username}-org`); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/new"); + await page.waitForLoadState("networkidle"); + + await test.step("Basic info", async () => { + // Check required fields + await page.locator("button[type=submit]").click(); + await expect(page.locator(".text-red-700")).toHaveCount(3); + + // Happy path + await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`); + expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName); + expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain); + await page.locator("button[type=submit]").click(); + await page.waitForLoadState("networkidle"); + + // Check admin email about code verification + await expectInvitationEmailToBeReceived( + page, + emails, + `john@${orgOwner.username}-org.com`, + "Verify your email to create an organization" + ); + + await test.step("Verification", async () => { + // Code verification + await expect(page.locator("#modal-title")).toBeVisible(); + await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`)); + await page.locator("button:text('Verify')").click(); + + // Check admin email about DNS pending action + await expectInvitationEmailToBeReceived( + page, + emails, + "admin@example.com", + "New organization created: pending action" + ); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/set-password"); + }); + }); + + await test.step("Admin password", async () => { + // Check required fields + await page.locator("button[type=submit]").click(); + await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints + + // Happy path + await page.locator("input[name='password']").fill("ADMIN_user2023$"); + await page.locator("button[type=submit]").click(); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/about"); + }); + + await test.step("About the organization", async () => { + // Choosing an avatar + await page.locator('button:text("Upload")').click(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Choose a file...").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png")); + await page.locator('button:text("Save")').click(); + + // About text + await page.locator('textarea[name="about"]').fill("This is a testing org"); + await page.locator("button[type=submit]").click(); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/onboard-admins"); + }); + + await test.step("On-board administrators", async () => { + // Required field + await page.locator("button[type=submit]").click(); + + // Happy path + await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`); + await page.locator("button[type=submit]").click(); + + // Check if invited admin received the invitation email + await expectInvitationEmailToBeReceived( + page, + emails, + `rick@${orgDomain}.com`, + `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` + ); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/add-teams"); + }); + + await test.step("Create teams", async () => { + // Initial state + await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1); + await expect(page.locator('button:text("Continue")')).toBeDisabled(); + + // Filling one team + await page.locator('input[name="teams.0.name"]').fill("Marketing"); + await expect(page.locator('button:text("Continue")')).toBeEnabled(); + + // Adding another team + await page.locator('button:text("Add a team")').click(); + await expect(page.locator('button:text("Continue")')).toBeDisabled(); + await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1); + await page.locator('input[name="teams.1.name"]').fill("Sales"); + await expect(page.locator('button:text("Continue")')).toBeEnabled(); + + // Finishing the creation wizard + await page.locator('button:text("Continue")').click(); + await page.waitForURL("/event-types"); + }); + }); +}); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts new file mode 100644 index 0000000000..6561a01e55 --- /dev/null +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -0,0 +1,119 @@ +import { expect } from "@playwright/test"; + +import { test } from "../lib/fixtures"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("Organization", () => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); + const { team: org } = await orgOwner.getOrg(); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (external user)", async () => { + const invitedUserEmail = `rick@domain-${Date.now()}.com`; + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`, + "signup?token" + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link in new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await context.close(); + await newPage.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto("/settings/organizations/members"); + page.locator(`[data-testid="login-form"]`); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the organization by invite link", async () => { + // Get the invite link + await page.locator('button:text("Add")').click(); + await page.locator(`[data-testid="copy-invite-link-button"]`).click(); + const inviteLink = await clipboard.get(); + await page.waitForLoadState("networkidle"); + + // Follow invite link in new window + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("networkidle"); + + // Check required fields + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints + + // Happy path + await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`); + await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await inviteLinkPage.locator("button[type=submit]").click(); + await inviteLinkPage.waitForURL("/getting-started"); + }); + }); + + test("Invitation (verified)", async ({ browser, page, users, emails }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); + const { team: org } = await orgOwner.getOrg(); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@example.com`; + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com` + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + }); +}); diff --git a/package.json b/package.json index 4f74854f79..586870f28e 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@playwright/test": "^1.31.2", "@snaplet/copycat": "^0.3.0", "@testing-library/jest-dom": "^5.16.5", + "@types/jsdom": "^21.1.3", "@types/jsonwebtoken": "^9.0.3", "c8": "^7.13.0", "checkly": "latest", diff --git a/packages/features/ee/organizations/pages/settings/members.tsx b/packages/features/ee/organizations/pages/settings/members.tsx index 36ba6b64c5..9f40c42a57 100644 --- a/packages/features/ee/organizations/pages/settings/members.tsx +++ b/packages/features/ee/organizations/pages/settings/members.tsx @@ -11,24 +11,6 @@ const MembersView = () => {
- {/* {team && ( - <> - {isInviteOpen && ( - - )} - - )} */}
diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 4aeffb573c..fc8fcf90fc 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -25,6 +25,7 @@ import { TextAreaField, } from "@calcom/ui"; import { Link } from "@calcom/ui/components/icon"; +import type { Window as WindowWithClipboardValue } from "@calcom/web/playwright/fixtures/clipboard"; import type { PendingMember } from "../lib/types"; import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; @@ -92,8 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) const inviteLink = isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; - await navigator.clipboard.writeText(inviteLink); - showToast(t("invite_link_copied"), "success"); + try { + await navigator.clipboard.writeText(inviteLink); + showToast(t("invite_link_copied"), "success"); + } catch (e) { + if (process.env.NEXT_PUBLIC_IS_E2E) { + (window as WindowWithClipboardValue).E2E_CLIPBOARD_VALUE = inviteLink; + } + console.error(e); + } }; const options: MembershipRoleOption[] = useMemo(() => { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 21abc1bc9a..a2b9ecb3d6 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -204,12 +204,15 @@ export function UserListTable() { id: "teams", header: "Teams", cell: ({ row }) => { - const { teams, accepted } = row.original; + const { teams, accepted, email } = row.original; // TODO: Implement click to filter return (
{accepted ? null : ( - + Pending )} diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 1e0f9a2e03..1b5509afb5 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -7,12 +7,7 @@ import { sendAdminOrganizationNotification } from "@calcom/emails"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { - IS_TEAM_BILLING_ENABLED, - RESERVED_SUBDOMAINS, - IS_PRODUCTION, - WEBAPP_URL, -} from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; @@ -175,7 +170,6 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { return { user: { ...createOwnerOrg, password } }; } else { - if (!IS_PRODUCTION) return { checked: true }; const language = await getTranslation(input.language ?? "en", "common"); const secret = createHash("md5") diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts index 885bb3b6ac..17bfa84be5 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { totpRawCheck } from "@calcom/lib/totp"; import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -21,7 +22,10 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => { if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); - if (!IS_PRODUCTION) return true; + if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) { + logger.warn(`Skipping code verification in dev/E2E environment`); + return true; + } await checkRateLimitAndThrowError({ rateLimitingType: "core", identifier: email, diff --git a/yarn.lock b/yarn.lock index 4e231a4e0d..01cf0964ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13331,6 +13331,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.3": + version: 21.1.4 + resolution: "@types/jsdom@npm:21.1.4" + dependencies: + "@types/node": "*" + "@types/tough-cookie": "*" + parse5: ^7.0.0 + checksum: 915f619111dadd8d1bb7f12b6736c9d2e486911e1aed086de5fb003e7e40ae1e368da322dc04f2122ef47faf40ca75b9315ae2df3e8011f882dcf84660fb0d68 + languageName: node + linkType: hard + "@types/jsforce@npm:^1.11.0": version: 1.11.0 resolution: "@types/jsforce@npm:1.11.0" @@ -17197,6 +17208,7 @@ __metadata: "@playwright/test": ^1.31.2 "@snaplet/copycat": ^0.3.0 "@testing-library/jest-dom": ^5.16.5 + "@types/jsdom": ^21.1.3 "@types/jsonwebtoken": ^9.0.3 c8: ^7.13.0 checkly: latest From 16c5b070b61343b1b83664eda3ab84abc83be1ff Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:46:03 +0400 Subject: [PATCH 18/35] fix: Admin Logic for event-type API endpoint (#12482) * Fix Admin logic * chore: fix prettier --------- Co-authored-by: Udit Takkar --- apps/api/pages/api/event-types/_post.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 1531485e7b..6eeb59f5c9 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) { statusCode: 401, message: "ADMIN required for `userId`", }); + if (!isAdmin && body.teamId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `teamId`", + }); /* Admin users are required to pass in a userId or teamId */ - if (isAdmin && (!body.userId || !body.teamId)) + if (isAdmin && !body.userId && !body.teamId) throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" }); } From 4b060fc2cd12fde1670e7f2db8880c4eaf5c4af3 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Wed, 22 Nov 2023 15:19:37 +0530 Subject: [PATCH 19/35] fix: opening team invite link in email throws error on signup page (#12397) Co-authored-by: Keith Williams --- .../viewer/teams/inviteMember/inviteMember.handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 79d560050a..5eaebe0844 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -119,6 +119,11 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = identifier: usernameOrEmail, token, expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: team.id, + }, + }, }, }); From 828092c1d0e773e9e882c64ead31d88e4b3b7b73 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:18:09 +0000 Subject: [PATCH 20/35] chore: fix cal.ai price (#12485) --- packages/app-store/cal-ai/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json index e6718b7b5d..6ec6551057 100644 --- a/packages/app-store/cal-ai/config.json +++ b/packages/app-store/cal-ai/config.json @@ -15,7 +15,7 @@ "__template": "basic", "dirName": "cal-ai", "paid": { - "priceInUsd": 25, + "priceInUsd": 8, "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP", "mode": "subscription" } From 113195224aff7a9bc9237b22fcacda31c95ed0a9 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:23:54 +0000 Subject: [PATCH 21/35] chore: fixed cal.ai thumbnail (#12486) --- packages/features/tips/Tips.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/tips/Tips.tsx b/packages/features/tips/Tips.tsx index e0e3a14fac..3ac41350c3 100644 --- a/packages/features/tips/Tips.tsx +++ b/packages/features/tips/Tips.tsx @@ -96,7 +96,7 @@ export const tips = [ { id: 12, thumbnailUrl: - "https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2", + "https://cal.com/og-image-cal-ai.jpg", mediaLink: "https://go.cal.com/cal-ai", title: "Cal.ai", description: "Your personal AI scheduling assistant", From af2c6c08441336a0f25ce68194482a8bab8462d0 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:25:56 +0000 Subject: [PATCH 22/35] chore: ignore "platform" in pr-assign-team workflow (#12487) --- .github/workflows/pr-assign-team-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml index f6c02d1fd5..ecb601f75c 100644 --- a/.github/workflows/pr-assign-team-label.yml +++ b/.github/workflows/pr-assign-team-label.yml @@ -13,4 +13,4 @@ jobs: with: repo-token: ${{ secrets.GH_ACCESS_TOKEN }} organization-name: calcom - ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" + ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" From a3b5263b766fc66bc4e8af371dbbaa55ec047912 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 22 Nov 2023 16:12:19 +0530 Subject: [PATCH 23/35] chore: reset form on submission (#12465) --- .../features/eventtypes/components/CreateEventTypeDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 6fd6483d65..becaf6bcef 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -125,6 +125,7 @@ export default function CreateEventTypeDialog({ }), "success" ); + form.reset(); }, onError: (err) => { if (err instanceof HttpError) { From 73aa1e8a22c545ee857c4bb33240f618ddc3f4d4 Mon Sep 17 00:00:00 2001 From: Adugna Tadesse Date: Wed, 22 Nov 2023 14:01:29 +0300 Subject: [PATCH 24/35] outlook second account fix (#12013) Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- packages/app-store/office365calendar/api/add.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-store/office365calendar/api/add.ts b/packages/app-store/office365calendar/api/add.ts index 60e06d18b1..e087eab78a 100644 --- a/packages/app-store/office365calendar/api/add.ts +++ b/packages/app-store/office365calendar/api/add.ts @@ -20,6 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) response_type: "code", scope: scopes.join(" "), client_id, + prompt: "select_account", redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`, state, }; From cb7ddc455ad7334228cabb3f03552db22fb378f0 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 22 Nov 2023 16:56:43 +0530 Subject: [PATCH 25/35] chore: Add team invite tests (#12425) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/playwright/team/expects.ts | 29 ++++ .../playwright/team/team-invitation.e2e.ts | 124 ++++++++++++++++++ .../ee/teams/components/MemberListItem.tsx | 9 +- .../components/form/inputs/HintOrErrors.tsx | 9 +- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 apps/web/playwright/team/expects.ts create mode 100644 apps/web/playwright/team/team-invitation.e2e.ts diff --git a/apps/web/playwright/team/expects.ts b/apps/web/playwright/team/expects.ts new file mode 100644 index 0000000000..43e02063f6 --- /dev/null +++ b/apps/web/playwright/team/expects.ts @@ -0,0 +1,29 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; +import type { API, Messages } from "mailhog"; + +import { getEmailsReceivedByUser } from "../lib/testUtils"; + +export async function expectInvitationEmailToBeReceived( + page: Page, + emails: API | undefined, + userEmail: string, + subject: string, + returnLink?: string +) { + if (!emails) return null; + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(10000); + const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); + expect(receivedEmails?.total).toBe(1); + + const [firstReceivedEmail] = (receivedEmails as Messages).items; + + expect(firstReceivedEmail.subject).toBe(subject); + if (!returnLink) return; + const dom = new JSDOM(firstReceivedEmail.html); + const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); + return anchor?.getAttribute("href"); +} diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts new file mode 100644 index 0000000000..95505bf279 --- /dev/null +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -0,0 +1,124 @@ +import { expect } from "@playwright/test"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { test } from "../lib/fixtures"; +import { localize } from "../lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("Team", () => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + const t = await localize("en"); + const teamOwner = await users.create(undefined, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the team by email (external user)", async () => { + const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${team.name}'s admin invited you to join the team ${team.name} on Cal.com`, + "signup?token" + ); + + //Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link to new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + await newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3); + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await newPage.close(); + await context.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the team by invite link", async () => { + const user = await users.create({ + email: `user-invite-${Date.now()}@domain.com`, + password: "P4ssw0rd!", + }); + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator(`[data-testid="copy-invite-link-button"]`).click(); + const inviteLink = await clipboard.get(); + await page.waitForLoadState("networkidle"); + + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("domcontentloaded"); + + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2); + + await inviteLinkPage.locator("input[name=email]").fill(user.email); + await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!"); + await inviteLinkPage.locator("button[type=submit]").click(); + + await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`); + }); + }); + + test("Invitation (verified)", async ({ browser, page, users, emails }) => { + const t = await localize("en"); + const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@example.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${teamOwner.name} invited you to join the team ${team.name} on Cal.com` + ); + + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + }); + }); +}); diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 2b356747ca..2f2bacfa32 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -152,7 +152,14 @@ export default function MemberListItem(props: Props) { {props.member.role && }
- + {props.member.email} {bookingLink && ( diff --git a/packages/ui/components/form/inputs/HintOrErrors.tsx b/packages/ui/components/form/inputs/HintOrErrors.tsx index a2115f7c56..adc3ce6fca 100644 --- a/packages/ui/components/form/inputs/HintOrErrors.tsx +++ b/packages/ui/components/form/inputs/HintOrErrors.tsx @@ -50,7 +50,10 @@ export function HintsOrErrors({ return (
  • + data-testid="hint-error" + className={ + error !== undefined ? (submitted ? "bg-yellow-200 text-red-700" : "") : "text-green-600" + }> {error !== undefined ? ( submitted ? ( @@ -72,7 +75,9 @@ export function HintsOrErrors({ // errors exist, not custom ones, just show them as is if (fieldErrors) { return ( -
    +
    From d04226ab9aef6ed58f5b39923e383e7fcfcb3e2d Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:39:00 +0200 Subject: [PATCH 26/35] fix: alby payment isPaid always false on create (#12463) --- packages/app-store/alby/lib/PaymentService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/alby/lib/PaymentService.ts b/packages/app-store/alby/lib/PaymentService.ts index 71e9c3e851..c29b08427a 100644 --- a/packages/app-store/alby/lib/PaymentService.ts +++ b/packages/app-store/alby/lib/PaymentService.ts @@ -74,7 +74,7 @@ export class PaymentService implements IAbstractPaymentService { currency: payment.currency, data: Object.assign( {}, - { invoice: { ...invoice, isPaid: await invoice.isPaid() } } + { invoice: { ...invoice, isPaid: false } } ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, From 2853288f497bd9710b732b6a8280c0c360eb8561 Mon Sep 17 00:00:00 2001 From: sebzz Date: Wed, 22 Nov 2023 17:19:27 +0530 Subject: [PATCH 27/35] docs: add google credentials in example env (#11695) * docs:add google credentials in example env * docs: add a space after # * chore: update .env.example --------- Co-authored-by: Udit Takkar --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 46237514b5..dfa0a49d66 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# Google OAuth credentials +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= From 9a6683e01dace9cbe426ec35084e38201555085d Mon Sep 17 00:00:00 2001 From: Matt Nicolls <2540582+nicolls1@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:04:51 +0100 Subject: [PATCH 28/35] fix: include eventTypeId in BOOKING_CANCELLED event (#12445) --- packages/features/bookings/lib/handleCancelBooking.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 217972f873..7148be943f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -227,6 +227,7 @@ async function handler(req: CustomRequest) { type: bookingToDelete?.eventType?.slug as string, description: bookingToDelete?.description || "", customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), + eventTypeId: bookingToDelete.eventTypeId as number, ...getCalEventResponses({ bookingFields: bookingToDelete.eventType?.bookingFields ?? null, booking: bookingToDelete, From 2498785c49b44e13862f75346c245701792200f3 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 22 Nov 2023 08:22:03 -0500 Subject: [PATCH 29/35] chore: Clean Up Delete Credential Selected Calendar Error Message (#12353) --- .../deleteCredential.handler.ts | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts index fb716afb84..e396d5e0d4 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -1,4 +1,3 @@ -import { Prisma } from "@prisma/client"; import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; @@ -328,52 +327,37 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp } } + // Backwards compatibility. Selected calendars cascade on delete when deleting a credential + // If it's a calendar remove it from the SelectedCalendars + if (credential.app?.categories.includes(AppCategories.calendar)) { + try { + const calendar = await getCalendar(credential); + + const calendars = await calendar?.listCalendars(); + + const calendarIds = calendars?.map((cal) => cal.externalId); + + await prisma.selectedCalendar.deleteMany({ + where: { + userId: user.id, + integration: credential.type as string, + externalId: { + in: calendarIds, + }, + }, + }); + } catch (error) { + console.warn( + `Error deleting selected calendars for userId: ${user.id} integration: ${credential.type}`, + error + ); + } + } + // Validated that credential is user's above await prisma.credential.delete({ where: { id: id, }, }); - - // Backwards compatibility. Selected calendars cascade on delete when deleting a credential - // If it's a calendar remove it from the SelectedCalendars - if (credential.app?.categories.includes(AppCategories.calendar)) { - const selectedCalendars = await prisma.selectedCalendar.findMany({ - where: { - userId: user.id, - integration: credential.type as string, - }, - }); - - if (selectedCalendars.length) { - const calendar = await getCalendar(credential); - - const calendars = await calendar?.listCalendars(); - - if (calendars && calendars.length > 0) { - calendars.map(async (cal) => { - prisma.selectedCalendar - .delete({ - where: { - userId_integration_externalId: { - userId: user.id, - externalId: cal.externalId, - integration: cal.integration as string, - }, - }, - }) - .catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId}. Could not find selected calendar.` - ); - } - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId} with error: ${error}` - ); - }); - }); - } - } - } }; From b762f602144a429c9580b4e3325ce7730161efef Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 22 Nov 2023 09:15:47 -0500 Subject: [PATCH 30/35] test: Integration Test GCal Primary Calendar (#12011) Co-authored-by: Alex van Andel --- .env.example | 6 + apps/web/playwright/fixtures/users.ts | 12 +- .../lib/CalendarService.test.ts | 20 +- .../googlecalendar/lib/CalendarService.ts | 2 +- .../tests/google-calendar.e2e.ts | 215 ++++++++++++++++++ .../googlecalendar/tests/testUtils.ts | 127 +++++++++++ packages/prisma/seed.ts | 16 ++ turbo.json | 4 + 8 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 packages/app-store/googlecalendar/tests/google-calendar.e2e.ts create mode 100644 packages/app-store/googlecalendar/tests/testUtils.ts diff --git a/.env.example b/.env.example index dfa0a49d66..3690d058f9 100644 --- a/.env.example +++ b/.env.example @@ -250,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL= E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_PASSWORD="" +# - CALCOM QA ACCOUNT +# Used for E2E tests on Cal.com that require 3rd party integrations +E2E_TEST_CALCOM_QA_EMAIL="qa@example.com" +# Replace with your own password +E2E_TEST_CALCOM_QA_PASSWORD="password" + # - APP CREDENTIAL SYNC *********************************************************************************** # Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations # Under settings/admin/apps ensure that all app secrets are set the same as the parent application diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index ae5fbfbec2..b0d0a48c65 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -396,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn await prisma.user.delete({ where: { id } }); store.users = store.users.filter((b) => b.id !== id); }, + set: async (email: string) => { + const user = await prisma.user.findUniqueOrThrow({ + where: { email }, + include: userIncludes, + }); + const userFixture = createUserFixture(user, store.page); + store.users.push(userFixture); + return userFixture; + }, }; }; @@ -420,7 +429,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { eventTypes: user.eventTypes, routingForms: user.routingForms, self, - apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page), + apiLogin: async (password?: string) => + apiLogin({ ...(await self()), password: password || user.username }, store.page), /** * @deprecated use apiLogin instead */ diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index 8a416ea6eb..8cf8f5b247 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -78,17 +78,15 @@ test("Calendar Cache is being called", async () => { // prismaMock.calendarCache.create.mock. const calendarService = new CalendarService(testCredential); - // @ts-expect-error authedCalendar is a private method, hence the TS error - vi.spyOn(calendarService, "authedCalendar").mockReturnValue( - // @ts-expect-error trust me bro - { - freebusy: { - query: vi.fn().mockReturnValue({ - data: testFreeBusyResponse, - }), - }, - } - ); + vi.spyOn(calendarService, "authedCalendar").mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore - Mocking the authedCalendar so can't return the actual response + freebusy: { + query: vi.fn().mockReturnValue({ + data: testFreeBusyResponse, + }), + }, + }); await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ testSelectedCalendar, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index f3af3a9cff..e01982378b 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -132,7 +132,7 @@ export default class GoogleCalendarService implements Calendar { }; }; - private authedCalendar = async () => { + public authedCalendar = async () => { const myGoogleAuth = await this.auth.getToken(); const calendar = google.calendar({ version: "v3", diff --git a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts new file mode 100644 index 0000000000..226b7a61cd --- /dev/null +++ b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts @@ -0,0 +1,215 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import dayjs from "@calcom/dayjs"; +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { test } from "@calcom/web/playwright/lib/fixtures"; +import { selectSecondAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; +import { createBookingAndFetchGCalEvent, deleteBookingAndEvent, assertValueExists } from "./testUtils"; + +test.describe("Google Calendar", async () => { + test.describe("Test using the primary calendar", async () => { + let qaUsername: string; + let qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }>; + test.beforeAll(async () => { + let runIntegrationTest = false; + + test.skip(!!APP_CREDENTIAL_SHARING_ENABLED, "Credential sharing enabled"); + + if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { + qaGCalCredential = await prisma.credential.findFirstOrThrow({ + where: { + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + type: metadata.type, + }, + select: { + id: true, + }, + }); + + const qaUserQuery = await prisma.user.findFirstOrThrow({ + where: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + select: { + username: true, + }, + }); + + assertValueExists(qaUserQuery.username, "qaUsername"); + qaUsername = qaUserQuery.username; + + if (qaGCalCredential && qaUsername) runIntegrationTest = true; + } + + test.skip(!runIntegrationTest, "QA user not found"); + }); + + test.beforeEach(async ({ page, users }) => { + assertValueExists(process.env.E2E_TEST_CALCOM_QA_EMAIL, "qaEmail"); + + const qaUserStore = await users.set(process.env.E2E_TEST_CALCOM_QA_EMAIL); + + await qaUserStore.apiLogin(process.env.E2E_TEST_CALCOM_QA_PASSWORD); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + assertValueExists(refreshedCredential, "refreshedCredential"); + + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const calendars = await googleCalendarService.listCalendars(); + + const primaryCalendarName = calendars.find((calendar) => calendar.primary)?.name; + assertValueExists(primaryCalendarName, "primaryCalendarName"); + + await page.goto("/apps/installed/calendar"); + + await page.waitForSelector('[title*="Create events on"]'); + await page.locator('[title*="Create events on"]').locator("svg").click(); + await page.locator("#react-select-2-option-0-0").getByText(primaryCalendarName).click(); + }); + + test("On new booking, event should be created on GCal", async ({ page }) => { + const { gCalEvent, gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page as Page, + qaGCalCredential, + qaUsername + ); + + assertValueExists(gCalEvent.start?.timeZone, "gCalEvent"); + assertValueExists(gCalEvent.end?.timeZone, "gCalEvent"); + + // Ensure that the start and end times are matching + const startTimeMatches = dayjs(booking.startTime).isSame( + dayjs(gCalEvent.start.dateTime).tz(gCalEvent.start.timeZone) + ); + const endTimeMatches = dayjs(booking.endTime).isSame( + dayjs(gCalEvent.end?.dateTime).tz(gCalEvent.end.timeZone) + ); + expect(startTimeMatches && endTimeMatches).toBe(true); + + // Ensure that the titles are matching + expect(booking.title).toBe(gCalEvent.summary); + + // Ensure that the attendee is on the event + const bookingAttendee = booking?.attendees[0].email; + const attendeeInGCalEvent = gCalEvent.attendees?.find((attendee) => attendee.email === bookingAttendee); + expect(attendeeInGCalEvent).toBeTruthy(); + + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + }); + + test("On reschedule, event should be updated on GCal", async ({ page }) => { + // Reschedule the booking and check the gCalEvent's time is also changed + // On reschedule gCal UID stays the same + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + await page.locator('[data-testid="reschedule-link"]').click(); + + await selectSecondAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + const rescheduledBookingUrl = await page.url(); + const rescheduledBookingUid = rescheduledBookingUrl.match(/booking\/([^\/?]+)/); + + assertValueExists(rescheduledBookingUid, "rescheduledBookingUid"); + + // Get the rescheduled booking start and end times + const rescheduledBooking = await prisma.booking.findFirst({ + where: { + uid: rescheduledBookingUid[1], + }, + select: { + startTime: true, + endTime: true, + }, + }); + assertValueExists(rescheduledBooking, "rescheduledBooking"); + + // The GCal event UID persists after reschedule but should get the rescheduled data + const gCalRescheduledEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalRescheduledEventResponse.status).toBe(200); + + const rescheduledGCalEvent = gCalRescheduledEventResponse.data; + + assertValueExists(rescheduledGCalEvent.start?.timeZone, "rescheduledGCalEvent"); + assertValueExists(rescheduledGCalEvent.end?.timeZone, "rescheduledGCalEvent"); + + // Ensure that the new start and end times are matching + const rescheduledStartTimeMatches = dayjs(rescheduledBooking.startTime).isSame( + dayjs(rescheduledGCalEvent.start?.dateTime).tz(rescheduledGCalEvent.start?.timeZone) + ); + const rescheduledEndTimeMatches = dayjs(rescheduledBooking.endTime).isSame( + dayjs(rescheduledGCalEvent.end?.dateTime).tz(rescheduledGCalEvent.end.timeZone) + ); + expect(rescheduledStartTimeMatches && rescheduledEndTimeMatches).toBe(true); + + // After test passes we can delete the bookings and GCal event + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + + await prisma.booking.delete({ + where: { + uid: rescheduledBookingUid[1], + }, + }); + }); + + test("When canceling the booking, the GCal event should also be deleted", async ({ page }) => { + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + // Cancel the booking + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="confirm_cancel"]').click(); + // Query for the bookingUID and ensure that it doesn't exist on GCal + + await page.waitForSelector('[data-testid="cancelled-headline"]'); + + const canceledGCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(canceledGCalEventResponse.data.status).toBe("cancelled"); + + // GCal API sees canceled events as already deleted + await deleteBookingAndEvent(authedCalendar, booking.uid); + }); + }); +}); diff --git a/packages/app-store/googlecalendar/tests/testUtils.ts b/packages/app-store/googlecalendar/tests/testUtils.ts new file mode 100644 index 0000000000..5d4920d2b1 --- /dev/null +++ b/packages/app-store/googlecalendar/tests/testUtils.ts @@ -0,0 +1,127 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { bookFirstEvent } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; + +/** + * Creates the booking on Cal.com and makes the GCal call to fetch the event. + * Ends on the booking success page + * @param page + * + * @returns the raw GCal event GET response and the booking reference + */ +export const createBookingAndFetchGCalEvent = async ( + page: Page, + qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }> | null, + qaUsername: string +) => { + await page.goto(`/${qaUsername}`); + await bookFirstEvent(page); + + const bookingUrl = await page.url(); + const bookingUid = bookingUrl.match(/booking\/([^\/?]+)/); + assertValueExists(bookingUid, "bookingUid"); + + const [gCalReference, booking] = await Promise.all([ + prisma.bookingReference.findFirst({ + where: { + booking: { + uid: bookingUid[1], + }, + type: metadata.type, + credentialId: qaGCalCredential?.id, + }, + select: { + uid: true, + booking: {}, + }, + }), + prisma.booking.findFirst({ + where: { + uid: bookingUid[1], + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + attendees: { + select: { + email: true, + }, + }, + user: { + select: { + email: true, + }, + }, + }, + }), + ]); + assertValueExists(gCalReference, "gCalReference"); + assertValueExists(booking, "booking"); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + expect(refreshedCredential).toBeTruthy(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const authedCalendar = await googleCalendarService.authedCalendar(); + + const gCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalEventResponse.status).toBe(200); + + return { gCalEvent: gCalEventResponse.data, gCalReference, booking, authedCalendar }; +}; + +export const deleteBookingAndEvent = async ( + authedCalendar: any, + bookingUid: string, + gCalReferenceUid?: string +) => { + // After test passes we can delete the booking and GCal event + await prisma.booking.delete({ + where: { + uid: bookingUid, + }, + }); + + if (gCalReferenceUid) { + await authedCalendar.events.delete({ + calendarId: "primary", + eventId: gCalReferenceUid, + }); + } +}; + +export function assertValueExists(value: unknown, variableName?: string): asserts value { + if (!value) { + throw new Error(`Value is not defined: ${variableName}`); + } +} diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 29d981a3a7..78c6861372 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -455,6 +455,22 @@ async function main() { }, }); + await createUserAndEventType({ + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", + password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", + username: "qa", + name: "QA Example", + }, + eventTypes: [ + { + title: "15min", + slug: "15min", + length: 15, + }, + ], + }); + await createTeamAndAddUsers( { name: "Seeded Team", diff --git a/turbo.json b/turbo.json index 1156aa13e5..0d7226a8ec 100644 --- a/turbo.json +++ b/turbo.json @@ -209,6 +209,8 @@ "CALCOM_CREDENTIAL_SYNC_ENDPOINT", "CALCOM_ENV", "CALCOM_LICENSE_KEY", + "CALCOM_QA_EMAIL", + "CALCOM_QA_PASSWORD", "CALCOM_TELEMETRY_DISABLED", "CALCOM_WEBHOOK_HEADER_NAME", "CALENDSO_ENCRYPTION_KEY", @@ -222,6 +224,8 @@ "DEBUG", "E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_PASSWORD", + "E2E_TEST_CALCOM_QA_EMAIL", + "E2E_TEST_CALCOM_QA_PASSWORD", "E2E_TEST_MAILHOG_ENABLED", "E2E_TEST_OIDC_CLIENT_ID", "E2E_TEST_OIDC_CLIENT_SECRET", From f65c7e413f4ffcad1039304a53b72998d6cb51a0 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 22 Nov 2023 23:13:25 +0530 Subject: [PATCH 31/35] fix: default organizer bug in managed event type (#11921) --- .../web/components/eventtype/EventTeamTab.tsx | 2 ++ .../playwright/fixtures/regularBookings.ts | 2 +- .../web/playwright/integrations-stripe.e2e.ts | 2 +- .../web/playwright/managed-event-types.e2e.ts | 32 +++++++++++++++++-- apps/web/playwright/payment-apps.e2e.ts | 6 ++-- .../components/EventTypeAppCardInterface.tsx | 2 ++ .../components/EventTypeAppCardInterface.tsx | 3 +- .../features/bookings/lib/handleNewBooking.ts | 5 ++- .../components/ChildrenEventTypeSelect.tsx | 1 + .../features/form-builder/FormBuilder.tsx | 1 + packages/ui/components/form/select/Select.tsx | 2 +- .../ui/components/form/select/components.tsx | 20 +++++++++++- .../ui/components/form/select/selectTheme.ts | 3 +- 13 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index 49917235a3..014fde0ed1 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
    { onChange && onChange( diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index 72c8e44fea..b0a84078e0 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -204,7 +204,7 @@ export function createBookingPageFixture(page: Page) { placeholder?: string ) => { await page.getByTestId("add-field").click(); - await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("test-field-type").click(); await page.getByTestId(`select-option-${questionType}`).click(); await page.getByLabel("Identifier").dblclick(); await page.getByLabel("Identifier").fill(identifier); diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index 25a1a33fa6..c9d86ccf0e 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -267,7 +267,7 @@ test.describe("Stripe integration", () => { await page.getByTestId("price-input-stripe").fill("200"); // Select currency in dropdown - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("stripe-currency-select").click(); await page.locator("#react-select-2-input").fill("mexi"); await page.locator("#react-select-2-option-81").click(); diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 52e6bf86c6..a0323ed8b7 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,6 +1,9 @@ import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils"; +import { localize } from "./lib/testUtils"; test.afterEach(({ users }) => users.deleteAll()); @@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => { await page.goto("/event-types"); await page.getByTestId("event-types").locator('a[title="managed"]').click(); await page.getByTestId("vertical-tab-assignment").click(); - await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click(); + await page.getByTestId("assignment-dropdown").click(); + await page.getByTestId(`select-option-${memberUser.id}`).click(); await page.locator('[type="submit"]').click(); await page.getByTestId("toast-success").waitFor(); + }); - await adminUser.logout(); + await test.step("Managed event type can use Organizer's default app as location", async () => { + await page.getByTestId("vertical-tab-event_setup_tab_title").click(); + + await page.locator("#location-select").click(); + const optionText = (await localize("en"))("organizer_default_conferencing_app"); + await page.locator(`text=${optionText}`).click(); + await page.locator("[data-testid=update-eventtype]").click(); + await page.getByTestId("toast-success").waitFor(); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("vertical-tab-assignment").click(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + + await expect(page.getByTestId("success-page")).toBeVisible(); }); await test.step("Managed event type has locked fields for added member", async () => { + await adminUser.logout(); + // Coming back as member user to see if there is a managed event present after assignment await memberUser.apiLogin(); await page.goto("/event-types"); @@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => { }); }); }); + +async function gotoBookingPage(page: Page) { + const previewLink = await page.getByTestId("preview-button").getAttribute("href"); + + await page.goto(previewLink ?? ""); +} diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts index c01bc10ba2..77bf674d92 100644 --- a/apps/web/playwright/payment-apps.e2e.ts +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -77,7 +77,7 @@ test.describe("Payment app", () => { await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("stripe-currency-select").click(); await page.getByTestId("select-option-usd").click(); await page.getByTestId("price-input-stripe").click(); @@ -123,10 +123,10 @@ test.describe("Payment app", () => { await page.getByPlaceholder("Price").click(); await page.getByPlaceholder("Price").fill("150"); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-currency-select").click(); await page.locator("#react-select-2-option-13").click(); - await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-payment-option-select").click(); await page.getByText("$MXNCurrencyMexican pesoPayment option").click(); await page.getByTestId("update-eventtype").click(); diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 536d159652..db6ba04755 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -92,6 +92,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ + data-testid="stripe-payment-option-select" defaultValue={ paymentOptionSelectValue ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2d73a22145..a21848ce2e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1001,8 +1001,11 @@ async function handler( const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); + + const isManagedEventType = !!eventType.parentId; + // use host default - if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) { + if ((isManagedEventType || isTeamEventType) && locationBodyString === OrganizerDefaultConferencingAppType) { const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata); const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; if (organizerMetadata?.defaultConferencingApp?.appSlug) { diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index 328644da30..8a6218d197 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -106,6 +106,7 @@ export const ChildrenEventTypeSelect = ({ {children.created && children.owner.username && (
    diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts index 7ee9f4bbb9..b4a0c0f46f 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -10,7 +10,7 @@ type TestTriggerOptions = { }; export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { - const { url, type, payloadTemplate = null } = input; + const { url, type, payloadTemplate = null, secret = null } = input; const translation = await getTranslation("en", "common"); const language = { locale: "en", @@ -40,8 +40,8 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption }; try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; - return await sendPayload(null, type, new Date().toISOString(), webhook, data); + const webhook = { subscriberUrl: url, appId: null, payloadTemplate }; + return await sendPayload(secret, type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts index 53f92f7e88..faeef8ed25 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts @@ -4,6 +4,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types"; export const ZTestTriggerInputSchema = webhookIdAndEventTypeIdSchema.extend({ url: z.string().url(), + secret: z.string().optional(), type: z.string(), payloadTemplate: z.string().optional().nullable(), }); From 2171a320f50b15e68c44348708c7a28e175ff411 Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 22 Nov 2023 12:19:16 -0700 Subject: [PATCH 35/35] fix: Locks Stripe version --- apps/web/package.json | 2 +- packages/app-store/package.json | 2 +- packages/app-store/stripepayment/package.json | 2 +- yarn.lock | 27 +++++++------------ 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 8d4fceeb46..0056cc4d2f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -125,7 +125,7 @@ "sanitize-html": "^2.10.0", "schema-dts": "^1.1.0", "short-uuid": "^4.2.0", - "stripe": "^14.3.0", + "stripe": "^9.16.0", "superjson": "1.9.1", "tailwindcss-radix": "^2.6.0", "turndown": "^7.1.1", diff --git a/packages/app-store/package.json b/packages/app-store/package.json index 6cfd20e06a..62225f2b65 100644 --- a/packages/app-store/package.json +++ b/packages/app-store/package.json @@ -26,7 +26,7 @@ "lodash": "^4.17.21", "qs-stringify": "^1.2.1", "react-i18next": "^12.2.0", - "stripe": "^14.3.0" + "stripe": "^9.16.0" }, "devDependencies": { "@calcom/types": "*" diff --git a/packages/app-store/stripepayment/package.json b/packages/app-store/stripepayment/package.json index dcf922cb2a..95c3e878e3 100644 --- a/packages/app-store/stripepayment/package.json +++ b/packages/app-store/stripepayment/package.json @@ -19,7 +19,7 @@ "@calcom/types": "*", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", - "stripe": "^14.3.0", + "stripe": "^9.16.0", "uuid": "^8.3.2", "zod": "^3.22.2" }, diff --git a/yarn.lock b/yarn.lock index d1f6feba02..1bdac79184 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3484,7 +3484,7 @@ __metadata: lodash: ^4.17.21 qs-stringify: ^1.2.1 react-i18next: ^12.2.0 - stripe: ^14.3.0 + stripe: ^9.16.0 languageName: unknown linkType: soft @@ -4335,7 +4335,7 @@ __metadata: "@calcom/types": "*" "@stripe/react-stripe-js": ^1.10.0 "@stripe/stripe-js": ^1.35.0 - stripe: ^14.3.0 + stripe: ^9.16.0 ts-node: ^10.9.1 uuid: ^8.3.2 zod: ^3.22.2 @@ -4633,7 +4633,7 @@ __metadata: sanitize-html: ^2.10.0 schema-dts: ^1.1.0 short-uuid: ^4.2.0 - stripe: ^14.3.0 + stripe: ^9.16.0 superjson: 1.9.1 tailwindcss: ^3.3.3 tailwindcss-animate: ^1.0.6 @@ -4755,7 +4755,7 @@ __metadata: remark: ^14.0.2 remark-html: ^14.0.1 remeda: ^1.24.1 - stripe: ^14.3.0 + stripe: ^9.16.0 tailwind-merge: ^1.13.2 tailwindcss: ^3.3.3 ts-node: ^10.9.1 @@ -33265,15 +33265,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0": - version: 6.11.2 - resolution: "qs@npm:6.11.2" - dependencies: - side-channel: ^1.0.4 - checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b - languageName: node - linkType: hard - "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -37325,13 +37316,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^14.3.0": - version: 14.3.0 - resolution: "stripe@npm:14.3.0" +"stripe@npm:^9.16.0": + version: 9.16.0 + resolution: "stripe@npm:9.16.0" dependencies: "@types/node": ">=8.1.0" - qs: ^6.11.0 - checksum: 1aa0dec1fe8cd4c0d2a5378b9d3c69f7df505efdc86b8d6352e194d656129db83b9faaf189b5138fb5fd9a0b90e618dfcff854bb4773d289a0de0b65d0a94cb2 + qs: ^6.10.3 + checksum: d84eb9ef3fa0c50e1b62271bf822d3e9da22272ec7364ae8334db7277e42f657c42c10f6fa535c634c36081e17d1c8c5a1efc509b3747f84bfbe4cf2a94ade4b languageName: node linkType: hard