From 4cc7766f8846c5623acd3ff935c210e30656b6bc Mon Sep 17 00:00:00 2001 From: Richard Poelderl Date: Tue, 25 Apr 2023 20:59:15 +0200 Subject: [PATCH 01/12] add noindex tags for apps setup pages (#8518) --- apps/web/pages/apps/[slug]/setup.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx index c53b61aea1..ca9b08138b 100644 --- a/apps/web/pages/apps/[slug]/setup.tsx +++ b/apps/web/pages/apps/[slug]/setup.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { AppSetupPage } from "@calcom/app-store/_pages/setup"; import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps"; +import { HeadSeo } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -25,7 +26,13 @@ export default function SetupInformation(props: InferGetStaticPropsType; + return ( + <> + {/* So that the set up page does not get indexed by search engines */} + + + + ); } SetupInformation.PageWrapper = PageWrapper; From a4725920ffc4107cfd3202b21c39204bd80ba59c Mon Sep 17 00:00:00 2001 From: Nafees Nazik <84864519+G3root@users.noreply.github.com> Date: Wed, 26 Apr 2023 00:29:39 +0530 Subject: [PATCH 02/12] fix: font not loaded in cal video (#8517) --- apps/web/pages/video/[uid].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/pages/video/[uid].tsx b/apps/web/pages/video/[uid].tsx index 1e09536fe5..c148a4bb05 100644 --- a/apps/web/pages/video/[uid].tsx +++ b/apps/web/pages/video/[uid].tsx @@ -263,7 +263,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) { ); } -VideoMeetingInfo.PageWrapper = PageWrapper; +JoinCall.PageWrapper = PageWrapper; export async function getServerSideProps(context: GetServerSidePropsContext) { const { req, res } = context; From 1eeb91a793e577b10c3037a2158d7ad052659b20 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 26 Apr 2023 08:39:47 +1000 Subject: [PATCH 03/12] perf: lazy load tRPC routes (#8167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * experiment: cold start perf * fix: update failing test * chore: add database indexes * chore: use json protocol and add query batching back * Update [status].tsx * Update [trpc].ts * Delete getSlimSession.ts * Update createContext.ts * remove trpc caller * correctly import Prisma * lazy ethRouter * replace crypto with md5 * import fixes * public event endpoint refactor * Update yarn.lock * Update yarn.lock * Using yarn.lock from main --------- Co-authored-by: Omar López Co-authored-by: Peer Richelsen Co-authored-by: Efraín Rochín Co-authored-by: Keith Williams --- .vscode/settings.json | 2 +- apps/storybook/.storybook/main.js | 2 +- .../web/components/booking/AvailableTimes.tsx | 2 +- .../components/booking/BookingListItem.tsx | 6 +- apps/web/pages/api/link.ts | 5 +- apps/web/test/lib/getSchedule.test.ts | 262 ++- .../app-store/rainbow/trpc/balance.handler.ts | 25 + .../app-store/rainbow/trpc/balance.schema.ts | 19 + .../rainbow/trpc/contract.handler.ts | 42 + .../app-store/rainbow/trpc/contract.schema.ts | 19 + packages/app-store/rainbow/trpc/router.ts | 121 +- .../components/event-meta/event.mock.ts | 2 +- .../bookings/layout/BookingLayout.tsx | 5 +- packages/features/ee/payments/api/webhook.ts | 2 +- .../features/insights/server/trpc-router.ts | 6 +- .../schedules/lib/use-schedule/index.ts | 2 +- .../schedules/lib/use-schedule/types.ts | 2 +- .../webhooks/pages/webhook-edit-view.tsx | 3 +- packages/lib/perf.ts | 8 + packages/trpc/react/trpc.ts | 2 +- packages/trpc/server/createContext.ts | 2 + packages/trpc/server/routers/_app.ts | 2 +- .../server/routers/loggedInViewer/_router.tsx | 369 ++++ .../routers/loggedInViewer/appById.handler.ts | 31 + .../routers/loggedInViewer/appById.schema.ts | 7 + .../appCredentialsByType.handler.ts | 15 + .../appCredentialsByType.schema.ts | 7 + .../routers/loggedInViewer/apps.handler.ts | 24 + .../routers/loggedInViewer/apps.schema.ts | 7 + .../routers/loggedInViewer/avatar.handler.ts | 13 + .../routers/loggedInViewer/avatar.schema.ts | 1 + .../routers/loggedInViewer/away.handler.ts | 22 + .../routers/loggedInViewer/away.schema.ts | 7 + .../bookingUnconfirmedCount.handler.ts | 38 + .../bookingUnconfirmedCount.schema.ts | 1 + .../connectedCalendars.handler.ts | 90 + .../connectedCalendars.schema.ts | 1 + .../deleteCredential.handler.ts | 345 ++++ .../loggedInViewer/deleteCredential.schema.ts | 8 + .../loggedInViewer/deleteMe.handler.ts | 87 + .../routers/loggedInViewer/deleteMe.schema.ts | 8 + .../deleteMeWithoutPassword.handler.ts | 46 + .../deleteMeWithoutPassword.schema.ts | 1 + .../loggedInViewer/eventTypeOrder.handler.ts | 69 + .../loggedInViewer/eventTypeOrder.schema.ts | 7 + .../getCalVideoRecordings.handler.ts | 26 + .../getCalVideoRecordings.schema.ts | 7 + ...ownloadLinkOfCalVideoRecordings.handler.ts | 38 + ...DownloadLinkOfCalVideoRecordings.schema.ts | 9 + .../getUsersDefaultConferencingApp.handler.ts | 14 + .../getUsersDefaultConferencingApp.schema.ts | 1 + .../loggedInViewer/integrations.handler.ts | 52 + .../loggedInViewer/integrations.schema.ts | 9 + .../loggedInViewer/locationOptions.handler.ts | 35 + .../loggedInViewer/locationOptions.schema.ts | 1 + .../routers/loggedInViewer/me.handler.ts | 41 + .../routers/loggedInViewer/me.schema.ts | 1 + .../setDestinationCalendar.handler.ts | 65 + .../setDestinationCalendar.schema.ts | 10 + .../loggedInViewer/stripeCustomer.handler.ts | 50 + .../loggedInViewer/stripeCustomer.schema.ts | 1 + .../loggedInViewer/submitFeedback.handler.ts | 35 + .../loggedInViewer/submitFeedback.schema.ts | 8 + .../loggedInViewer/updateProfile.handler.ts | 140 ++ .../loggedInViewer/updateProfile.schema.ts | 26 + ...pdateUserDefaultConferencingApp.handler.ts | 59 + ...updateUserDefaultConferencingApp.schema.ts | 10 + .../server/routers/publicViewer/_router.tsx | 140 ++ .../publicViewer/cityTimezones.handler.ts | 38 + .../publicViewer/cityTimezones.schema.ts | 1 + .../publicViewer/countryCode.handler.ts | 12 + .../publicViewer/countryCode.schema.ts | 1 + .../routers/publicViewer/event.handler.ts | 15 + .../routers/publicViewer/event.schema.ts | 8 + .../routers/publicViewer/i18n.handler.ts | 15 + .../routers/publicViewer/i18n.schema.ts | 1 + .../publicViewer/samlTenantProduct.handler.ts | 19 + .../publicViewer/samlTenantProduct.schema.ts | 7 + .../routers/publicViewer/session.handler.ts | 11 + .../routers/publicViewer/session.schema.ts | 1 + .../stripeCheckoutSession.handler.ts | 72 + .../stripeCheckoutSession.schema.ts | 8 + packages/trpc/server/routers/viewer.tsx | 1356 -------------- .../trpc/server/routers/viewer/_router.tsx | 50 + .../trpc/server/routers/viewer/apiKeys.tsx | 153 -- .../server/routers/viewer/apiKeys/_router.tsx | 101 ++ .../routers/viewer/apiKeys/create.handler.ts | 38 + .../routers/viewer/apiKeys/create.schema.ts | 10 + .../routers/viewer/apiKeys/delete.handler.ts | 48 + .../routers/viewer/apiKeys/delete.schema.ts | 8 + .../routers/viewer/apiKeys/edit.handler.ts | 42 + .../routers/viewer/apiKeys/edit.schema.ts | 9 + .../viewer/apiKeys/findKeyOfType.handler.ts | 26 + .../viewer/apiKeys/findKeyOfType.schema.ts | 7 + .../routers/viewer/apiKeys/list.handler.ts | 28 + .../routers/viewer/apiKeys/list.schema.ts | 1 + packages/trpc/server/routers/viewer/apps.tsx | 357 ---- .../server/routers/viewer/apps/_router.tsx | 126 ++ .../viewer/apps/checkForGCal.handler.ts | 20 + .../viewer/apps/checkForGCal.schema.ts | 1 + .../routers/viewer/apps/listLocal.handler.ts | 89 + .../routers/viewer/apps/listLocal.schema.ts | 8 + .../apps/queryForDependencies.handler.ts | 35 + .../apps/queryForDependencies.schema.ts | 5 + .../routers/viewer/apps/saveKeys.handler.ts | 49 + .../routers/viewer/apps/saveKeys.schema.ts | 12 + .../routers/viewer/apps/toggle.handler.ts | 166 ++ .../routers/viewer/apps/toggle.schema.ts | 8 + .../trpc/server/routers/viewer/apps/types.ts | 14 + .../apps/updateAppCredentials.handler.ts | 49 + .../apps/updateAppCredentials.schema.ts | 8 + packages/trpc/server/routers/viewer/auth.tsx | 93 - .../server/routers/viewer/auth/_router.tsx | 48 + .../viewer/auth/changePassword.handler.ts | 66 + .../viewer/auth/changePassword.schema.ts | 8 + .../viewer/auth/verifyPassword.handler.ts | 34 + .../viewer/auth/verifyPassword.schema.ts | 7 + .../server/routers/viewer/availability.tsx | 499 ------ .../routers/viewer/availability/_router.tsx | 45 + .../viewer/availability/list.handler.ts | 49 + .../viewer/availability/list.schema.ts | 1 + .../viewer/availability/schedule/_router.tsx | 80 + .../availability/schedule/create.handler.ts | 73 + .../availability/schedule/create.schema.ts | 18 + .../availability/schedule/delete.handler.ts | 57 + .../availability/schedule/delete.schema.ts | 7 + .../availability/schedule/get.handler.ts | 100 ++ .../availability/schedule/get.schema.ts | 8 + .../availability/schedule/update.handler.ts | 121 ++ .../availability/schedule/update.schema.ts | 28 + .../viewer/availability/user.handler.ts | 12 + .../viewer/availability/user.schema.ts | 13 + .../routers/viewer/availability/util.ts | 84 + .../trpc/server/routers/viewer/bookings.tsx | 1039 ----------- .../routers/viewer/bookings/_router.tsx | 107 ++ .../viewer/bookings/confirm.handler.ts | 310 ++++ .../routers/viewer/bookings/confirm.schema.ts | 7 + .../viewer/bookings/editLocation.handler.ts | 116 ++ .../viewer/bookings/editLocation.schema.ts | 11 + .../routers/viewer/bookings/get.handler.ts | 306 ++++ .../routers/viewer/bookings/get.schema.ts | 14 + .../bookings/getBookingAttendees.handler.ts | 33 + .../bookings/getBookingAttendees.schema.ts | 5 + .../bookings/requestReschedule.handler.ts | 254 +++ .../bookings/requestReschedule.schema.ts | 8 + .../server/routers/viewer/bookings/types.ts | 12 + .../server/routers/viewer/bookings/util.ts | 80 + .../server/routers/viewer/deploymentSetup.tsx | 24 - .../viewer/deploymentSetup/_router.tsx | 26 + .../viewer/deploymentSetup/update.handler.ts | 19 + .../viewer/deploymentSetup/update.schema.ts | 7 + .../trpc/server/routers/viewer/eventTypes.ts | 952 ---------- .../routers/viewer/eventTypes/_router.ts | 207 +++ .../eventTypes/bulkEventFetch.handler.ts | 44 + .../eventTypes/bulkEventFetch.schema.ts | 1 + .../bulkUpdateToDefaultLocation.handler.ts | 52 + .../bulkUpdateToDefaultLocation.schema.ts | 7 + .../viewer/eventTypes/create.handler.ts | 93 + .../viewer/eventTypes/create.schema.ts | 7 + .../viewer/eventTypes/delete.handler.ts | 31 + .../viewer/eventTypes/delete.schema.ts | 7 + .../viewer/eventTypes/duplicate.handler.ts | 130 ++ .../viewer/eventTypes/duplicate.schema.ts | 7 + .../routers/viewer/eventTypes/get.handler.ts | 22 + .../routers/viewer/eventTypes/get.schema.ts | 7 + .../viewer/eventTypes/getByViewer.handler.ts | 222 +++ .../viewer/eventTypes/getByViewer.schema.ts | 1 + .../routers/viewer/eventTypes/list.handler.ts | 28 + .../routers/viewer/eventTypes/list.schema.ts | 1 + .../viewer/eventTypes/listWithTeam.handler.ts | 39 + .../viewer/eventTypes/listWithTeam.schema.ts | 1 + .../server/routers/viewer/eventTypes/types.ts | 57 + .../viewer/eventTypes/update.handler.ts | 240 +++ .../viewer/eventTypes/update.schema.ts | 7 + .../server/routers/viewer/eventTypes/util.ts | 141 ++ .../server/routers/viewer/payments/_router.ts | 28 + .../viewer/payments/chargeCard.handler.ts | 121 ++ .../viewer/payments/chargeCard.schema.ts | 7 + .../server/routers/viewer/payments/type.ts | 7 + .../server/routers/viewer/slots/_router.tsx | 59 + .../viewer/slots/getSchedule.handler.ts | 11 + .../viewer/slots/getSchedule.schema.ts | 7 + .../viewer/slots/reserveSlot.handler.ts | 61 + .../viewer/slots/reserveSlot.schema.ts | 7 + .../trpc/server/routers/viewer/slots/types.ts | 48 + .../viewer/{slots.ts => slots/util.ts} | 135 +- packages/trpc/server/routers/viewer/sso.tsx | 171 -- .../server/routers/viewer/sso/_router.tsx | 86 + .../routers/viewer/sso/delete.handler.ts | 39 + .../routers/viewer/sso/delete.schema.ts | 7 + .../server/routers/viewer/sso/get.handler.ts | 62 + .../server/routers/viewer/sso/get.schema.ts | 7 + .../routers/viewer/sso/update.handler.ts | 42 + .../routers/viewer/sso/update.schema.ts | 8 + .../routers/viewer/sso/updateOIDC.handler.ts | 44 + .../routers/viewer/sso/updateOIDC.schema.ts | 10 + packages/trpc/server/routers/viewer/teams.tsx | 739 -------- .../server/routers/viewer/teams/_router.tsx | 336 ++++ .../viewer/teams/acceptOrLeave.handler.ts | 51 + .../viewer/teams/acceptOrLeave.schema.ts | 8 + .../viewer/teams/changeMemberRole.handler.ts | 74 + .../viewer/teams/changeMemberRole.schema.ts | 10 + .../routers/viewer/teams/create.handler.ts | 71 + .../routers/viewer/teams/create.schema.ts | 15 + .../routers/viewer/teams/delete.handler.ts | 39 + .../routers/viewer/teams/delete.schema.ts | 7 + .../routers/viewer/teams/get.handler.ts | 35 + .../server/routers/viewer/teams/get.schema.ts | 7 + .../teams/getMemberAvailability.handler.ts | 48 + .../teams/getMemberAvailability.schema.ts | 11 + .../teams/getMembershipbyUser.handler.ts | 31 + .../teams/getMembershipbyUser.schema.ts | 8 + .../viewer/teams/getUpgradeable.handler.ts | 27 + .../viewer/teams/getUpgradeable.schema.ts | 1 + .../viewer/teams/hasTeamPlan.handler.ts | 24 + .../viewer/teams/hasTeamPlan.schema.ts | 1 + .../viewer/teams/inviteMember.handler.ts | 126 ++ .../viewer/teams/inviteMember.schema.ts | 12 + .../routers/viewer/teams/list.handler.ts | 27 + .../routers/viewer/teams/list.schema.ts | 1 + .../viewer/teams/listInvites.handler.ts | 20 + .../viewer/teams/listInvites.schema.ts | 1 + .../viewer/teams/listMembers.handler.ts | 52 + .../viewer/teams/listMembers.schema.ts | 7 + .../routers/viewer/teams/publish.handler.ts | 75 + .../routers/viewer/teams/publish.schema.ts | 7 + .../viewer/teams/removeMember.handler.ts | 51 + .../viewer/teams/removeMember.schema.ts | 8 + .../routers/viewer/teams/update.handler.ts | 82 + .../routers/viewer/teams/update.schema.ts | 16 + .../viewer/teams/updateMembership.handler.ts | 34 + .../viewer/teams/updateMembership.schema.ts | 9 + .../trpc/server/routers/viewer/teams/util.ts | 1 + .../trpc/server/routers/viewer/webhook.tsx | 271 --- .../server/routers/viewer/webhook/_router.tsx | 119 ++ .../routers/viewer/webhook/create.handler.ts | 32 + .../routers/viewer/webhook/create.schema.ts | 17 + .../routers/viewer/webhook/delete.handler.ts | 44 + .../routers/viewer/webhook/delete.schema.ts | 10 + .../routers/viewer/webhook/edit.handler.ts | 39 + .../routers/viewer/webhook/edit.schema.ts | 18 + .../routers/viewer/webhook/get.handler.ts | 27 + .../routers/viewer/webhook/get.schema.ts | 9 + .../routers/viewer/webhook/list.handler.ts | 31 + .../routers/viewer/webhook/list.schema.ts | 11 + .../viewer/webhook/testTrigger.handler.ts | 53 + .../viewer/webhook/testTrigger.schema.ts | 11 + .../server/routers/viewer/webhook/types.ts | 9 + .../server/routers/viewer/webhook/util.ts | 50 + .../trpc/server/routers/viewer/workflows.tsx | 1580 ----------------- .../routers/viewer/workflows/_router.tsx | 216 +++ .../workflows/activateEventType.handler.ts | 114 ++ .../workflows/activateEventType.schema.ts | 8 + .../viewer/workflows/create.handler.ts | 84 + .../routers/viewer/workflows/create.schema.ts | 7 + .../viewer/workflows/delete.handler.ts | 72 + .../routers/viewer/workflows/delete.schema.ts | 7 + .../routers/viewer/workflows/get.handler.ts | 58 + .../routers/viewer/workflows/get.schema.ts | 7 + .../viewer/workflows/getByViewer.handler.ts | 121 ++ .../viewer/workflows/getByViewer.schema.ts | 1 + .../workflows/getVerifiedNumbers.handler.ts | 22 + .../workflows/getVerifiedNumbers.schema.ts | 7 + .../getWorkflowActionOptions.handler.ts | 29 + .../getWorkflowActionOptions.schema.ts | 1 + .../routers/viewer/workflows/list.handler.ts | 152 ++ .../routers/viewer/workflows/list.schema.ts | 10 + .../workflows/sendVerificationCode.handler.ts | 16 + .../workflows/sendVerificationCode.schema.ts | 7 + .../viewer/workflows/update.handler.ts | 685 +++++++ .../routers/viewer/workflows/update.schema.ts | 34 + .../server/routers/viewer/workflows/util.ts | 116 ++ .../workflows/verifyPhoneNumber.handler.ts | 18 + .../workflows/verifyPhoneNumber.schema.ts | 9 + packages/trpc/server/trpc.ts | 5 + 275 files changed, 11251 insertions(+), 7614 deletions(-) create mode 100644 packages/app-store/rainbow/trpc/balance.handler.ts create mode 100644 packages/app-store/rainbow/trpc/balance.schema.ts create mode 100644 packages/app-store/rainbow/trpc/contract.handler.ts create mode 100644 packages/app-store/rainbow/trpc/contract.schema.ts create mode 100644 packages/lib/perf.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/_router.tsx create mode 100644 packages/trpc/server/routers/loggedInViewer/appById.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/appById.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/apps.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/apps.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/avatar.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/avatar.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/away.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/away.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/integrations.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/integrations.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/me.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/me.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/_router.tsx create mode 100644 packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/countryCode.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/countryCode.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/event.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/event.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/i18n.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/i18n.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/session.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/session.schema.ts create mode 100644 packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts create mode 100644 packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts delete mode 100644 packages/trpc/server/routers/viewer.tsx create mode 100644 packages/trpc/server/routers/viewer/_router.tsx delete mode 100644 packages/trpc/server/routers/viewer/apiKeys.tsx create mode 100644 packages/trpc/server/routers/viewer/apiKeys/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/apiKeys/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apiKeys/list.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/apps.tsx create mode 100644 packages/trpc/server/routers/viewer/apps/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apps/listLocal.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/listLocal.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apps/toggle.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/toggle.schema.ts create mode 100644 packages/trpc/server/routers/viewer/apps/types.ts create mode 100644 packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/auth.tsx create mode 100644 packages/trpc/server/routers/viewer/auth/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/auth/changePassword.handler.ts create mode 100644 packages/trpc/server/routers/viewer/auth/changePassword.schema.ts create mode 100644 packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts create mode 100644 packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/availability.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/list.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/user.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/user.schema.ts create mode 100644 packages/trpc/server/routers/viewer/availability/util.ts delete mode 100644 packages/trpc/server/routers/viewer/bookings.tsx create mode 100644 packages/trpc/server/routers/viewer/bookings/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/bookings/confirm.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/confirm.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/types.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/util.ts delete mode 100644 packages/trpc/server/routers/viewer/deploymentSetup.tsx create mode 100644 packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/eventTypes.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/_router.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/list.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/types.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/update.schema.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/util.ts create mode 100644 packages/trpc/server/routers/viewer/payments/_router.ts create mode 100644 packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts create mode 100644 packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts create mode 100644 packages/trpc/server/routers/viewer/payments/type.ts create mode 100644 packages/trpc/server/routers/viewer/slots/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts create mode 100644 packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts create mode 100644 packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts create mode 100644 packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts create mode 100644 packages/trpc/server/routers/viewer/slots/types.ts rename packages/trpc/server/routers/viewer/{slots.ts => slots/util.ts} (77%) delete mode 100644 packages/trpc/server/routers/viewer/sso.tsx create mode 100644 packages/trpc/server/routers/viewer/sso/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/sso/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/sso/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/sso/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/sso/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/sso/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/sso/update.schema.ts create mode 100644 packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts create mode 100644 packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/teams.tsx create mode 100644 packages/trpc/server/routers/viewer/teams/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/list.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listInvites.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listInvites.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listMembers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listMembers.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/publish.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/publish.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/removeMember.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/removeMember.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/update.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/util.ts delete mode 100644 packages/trpc/server/routers/viewer/webhook.tsx create mode 100644 packages/trpc/server/routers/viewer/webhook/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/webhook/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/edit.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/edit.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/list.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/types.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/util.ts delete mode 100644 packages/trpc/server/routers/viewer/workflows.tsx create mode 100644 packages/trpc/server/routers/viewer/workflows/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/list.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/update.schema.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/util.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 919ae19b36..4c07cd934a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,6 @@ }, "typescript.preferences.importModuleSpecifier": "non-relative", "spellright.language": ["en"], - "spellright.documentTypes": ["markdown", "typescript"], + "spellright.documentTypes": ["markdown", "typescript", "typescriptreact"], "tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]] } diff --git a/apps/storybook/.storybook/main.js b/apps/storybook/.storybook/main.js index 9b0d21eada..ae653ae06e 100644 --- a/apps/storybook/.storybook/main.js +++ b/apps/storybook/.storybook/main.js @@ -71,5 +71,5 @@ module.exports = { return config; }, - typescript: { reactDocgen: 'react-docgen' } + typescript: { reactDocgen: "react-docgen" }, }; diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 19cabe0b85..394225ba3a 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -11,7 +11,7 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { TimeFormat } from "@calcom/lib/timeFormat"; import { nameOfDay } from "@calcom/lib/weekday"; import { trpc } from "@calcom/trpc/react"; -import type { Slot } from "@calcom/trpc/server/routers/viewer/slots"; +import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui"; import classNames from "@lib/classNames"; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 64c7d24b09..0644aa5e0f 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -483,12 +483,12 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings i18n: { language }, } = useLocale(); const now = new Date(); - const recurringCount = recurringDates.filter((date) => { + const recurringCount = recurringDates.filter((recurringDate) => { return ( - date >= now && + recurringDate >= now && !booking.recurringInfo?.bookings[BookingStatus.CANCELLED] .map((date) => date.toDateString()) - .includes(date.toDateString()) + .includes(recurringDate.toDateString()) ); }).length; diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts index 9b692d95de..33a9e87ca2 100644 --- a/apps/web/pages/api/link.ts +++ b/apps/web/pages/api/link.ts @@ -7,7 +7,7 @@ import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import { TRPCError } from "@calcom/trpc/server"; import { createContext } from "@calcom/trpc/server/createContext"; -import { viewerRouter } from "@calcom/trpc/server/routers/viewer"; +import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router"; enum DirectAction { ACCEPT = "accept", @@ -51,7 +51,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { try { /** @see https://trpc.io/docs/server-side-calls */ const ctx = await createContext({ req, res }, sessionGetter); - const caller = viewerRouter.createCaller(ctx); + const caller = viewerRouter.createCaller({ ...ctx, req, res }); + await caller.bookings.confirm({ bookingId: booking.id, recurringEventId: booking.recurringEventId || undefined, diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index ec19757ca2..c00df7e1d5 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -15,15 +15,14 @@ import { v4 as uuidv4 } from "uuid"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { BookingStatus } from "@calcom/prisma/client"; -import type { Slot } from "@calcom/trpc/server/routers/viewer/slots"; -import { getSchedule } from "@calcom/trpc/server/routers/viewer/slots"; +import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; +import { getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; import { prismaMock, CalendarManagerMock } from "../../../../tests/config/singleton"; // TODO: Mock properly prismaMock.eventType.findUnique.mockResolvedValue(null); prismaMock.user.findMany.mockResolvedValue([]); -prismaMock.selectedSlots.findMany.mockResolvedValue([]); jest.mock("@calcom/lib/constants", () => ({ IS_PRODUCTION: true, @@ -271,16 +270,13 @@ describe("getSchedule", () => { end: `${plus2DateString}T23:00:00.000Z`, }, ]); - const scheduleForDayWithAGoogleCalendarBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], { @@ -357,17 +353,14 @@ describe("getSchedule", () => { }); // Day Plus 2 is completely free - It only has non accepted bookings - const scheduleOnCompletelyFreeDay = await getSchedule( - { - eventTypeId: 1, - // EventTypeSlug doesn't matter for non-dynamic events - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleOnCompletelyFreeDay = await getSchedule({ + eventTypeId: 1, + // EventTypeSlug doesn't matter for non-dynamic events + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // getSchedule returns timeslots in GMT expect(scheduleOnCompletelyFreeDay).toHaveTimeSlots( @@ -390,16 +383,13 @@ describe("getSchedule", () => { ); // Day plus 3 - const scheduleForDayWithOneBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForDayWithOneBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForDayWithOneBooking).toHaveTimeSlots( [ @@ -455,16 +445,13 @@ describe("getSchedule", () => { }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); - const scheduleForEventWith30Length = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWith30Length = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWith30Length).toHaveTimeSlots( [ `04:00:00.000Z`, @@ -490,16 +477,13 @@ describe("getSchedule", () => { } ); - const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // `slotInterval` takes precedence over `length` expect(scheduleForEventWith30minsLengthAndSlotInterval2hrs).toHaveTimeSlots( [`04:00:00.000Z`, `06:00:00.000Z`, `08:00:00.000Z`, `10:00:00.000Z`, `12:00:00.000Z`], @@ -553,16 +537,13 @@ describe("getSchedule", () => { }); const { dateString: todayDateString } = getDate(); const { dateString: minus1DateString } = getDate({ dateIncrement: -1 }); - const scheduleForEventWithBookingNotice13Hrs = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${minus1DateString}T18:30:00.000Z`, - endTime: `${todayDateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWithBookingNotice13Hrs = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${minus1DateString}T18:30:00.000Z`, + endTime: `${todayDateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots( [ /*`04:00:00.000Z`, `06:00:00.000Z`, - Minimum time slot is 07:30 UTC*/ `08:00:00.000Z`, @@ -574,16 +555,13 @@ describe("getSchedule", () => { } ); - const scheduleForEventWithBookingNotice10Hrs = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${minus1DateString}T18:30:00.000Z`, - endTime: `${todayDateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventWithBookingNotice10Hrs = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${minus1DateString}T18:30:00.000Z`, + endTime: `${todayDateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots( [ /*`04:00:00.000Z`, - Minimum bookable time slot is 04:30 UTC but next available is 06:00*/ @@ -639,16 +617,13 @@ describe("getSchedule", () => { }, ]); - const scheduleForEventOnADayWithNonCalBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithNonCalBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots( [ @@ -714,16 +689,13 @@ describe("getSchedule", () => { }, ]); - const scheduleForEventOnADayWithCalBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithCalBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots( [ @@ -767,16 +739,13 @@ describe("getSchedule", () => { createBookingScenario(scenarioData); - const scheduleForEventOnADayWithDateOverride = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForEventOnADayWithDateOverride = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots( ["08:30:00.000Z", "09:30:00.000Z", "10:30:00.000Z", "11:30:00.000Z"], @@ -853,16 +822,13 @@ describe("getSchedule", () => { // Requesting this user's availability for their // individual Event Type - const thisUserAvailability = await getSchedule( - { - eventTypeId: 2, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const thisUserAvailability = await getSchedule({ + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(thisUserAvailability).toHaveTimeSlots( [ @@ -951,16 +917,13 @@ describe("getSchedule", () => { hosts: [], }); - const scheduleForTeamEventOnADayWithNoBooking = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${todayDateString}T18:30:00.000Z`, - endTime: `${plus1DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${todayDateString}T18:30:00.000Z`, + endTime: `${plus1DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots( [ @@ -981,16 +944,13 @@ describe("getSchedule", () => { } ); - const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability // It's a collective availability, so both user 101 and 102 are considered for timeslots expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots( @@ -1088,16 +1048,13 @@ describe("getSchedule", () => { ], hosts: [], }); - const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus1DateString}T18:30:00.000Z`, - endTime: `${plus2DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots( [ @@ -1116,16 +1073,13 @@ describe("getSchedule", () => { { dateString: plus2DateString } ); - const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule( - { - eventTypeId: 1, - eventTypeSlug: "", - startTime: `${plus2DateString}T18:30:00.000Z`, - endTime: `${plus3DateString}T18:29:59.999Z`, - timeZone: Timezones["+5:30"], - }, - ctx - ); + const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus2DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); // A user with blocked time in another event, still affects Team Event availability expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots( [ diff --git a/packages/app-store/rainbow/trpc/balance.handler.ts b/packages/app-store/rainbow/trpc/balance.handler.ts new file mode 100644 index 0000000000..e928ace524 --- /dev/null +++ b/packages/app-store/rainbow/trpc/balance.handler.ts @@ -0,0 +1,25 @@ +import { checkBalance } from "../utils/ethereum"; +import type { TBalanceInputSchema } from "./balance.schema"; + +interface BalanceHandlerOptions { + input: TBalanceInputSchema; +} + +export const balanceHandler = async ({ input }: BalanceHandlerOptions) => { + const { address, tokenAddress, chainId } = input; + try { + const hasBalance = await checkBalance(address, tokenAddress, chainId); + + return { + data: { + hasBalance, + }, + }; + } catch (e) { + return { + data: { + hasBalance: false, + }, + }; + } +}; diff --git a/packages/app-store/rainbow/trpc/balance.schema.ts b/packages/app-store/rainbow/trpc/balance.schema.ts new file mode 100644 index 0000000000..1828df4999 --- /dev/null +++ b/packages/app-store/rainbow/trpc/balance.schema.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +export const ZBalanceInputSchema = z.object({ + address: z.string(), + tokenAddress: z.string(), + chainId: z.number(), +}); + +export const ZBalanceOutputSchema = z.object({ + data: z + .object({ + hasBalance: z.boolean(), + }) + .nullish(), + error: z.string().nullish(), +}); + +export type TBalanceOutputSchema = z.infer; +export type TBalanceInputSchema = z.infer; diff --git a/packages/app-store/rainbow/trpc/contract.handler.ts b/packages/app-store/rainbow/trpc/contract.handler.ts new file mode 100644 index 0000000000..6f5463e0c8 --- /dev/null +++ b/packages/app-store/rainbow/trpc/contract.handler.ts @@ -0,0 +1,42 @@ +import { ethers } from "ethers"; +import { configureChains, createClient } from "wagmi"; + +import abi from "../utils/abi.json"; +import { getProviders, SUPPORTED_CHAINS } from "../utils/ethereum"; +import type { TContractInputSchema } from "./contract.schema"; + +interface ContractHandlerOptions { + input: TContractInputSchema; +} +export const contractHandler = async ({ input }: ContractHandlerOptions) => { + const { address, chainId } = input; + const { provider } = configureChains( + SUPPORTED_CHAINS.filter((chain) => chain.id === chainId), + getProviders() + ); + + const client = createClient({ + provider, + }); + + const contract = new ethers.Contract(address, abi, client.provider); + + try { + const name = await contract.name(); + const symbol = await contract.symbol(); + + return { + data: { + name, + symbol: `$${symbol}`, + }, + }; + } catch (e) { + return { + data: { + name: address, + symbol: "$UNKNOWN", + }, + }; + } +}; diff --git a/packages/app-store/rainbow/trpc/contract.schema.ts b/packages/app-store/rainbow/trpc/contract.schema.ts new file mode 100644 index 0000000000..82e8c5fe9b --- /dev/null +++ b/packages/app-store/rainbow/trpc/contract.schema.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +export const ZContractInputSchema = z.object({ + address: z.string(), + chainId: z.number(), +}); + +export const ZContractOutputSchema = z.object({ + data: z + .object({ + name: z.string(), + symbol: z.string(), + }) + .nullish(), + error: z.string().nullish(), +}); + +export type TContractInputSchema = z.infer; +export type TContractOutputSchema = z.infer; diff --git a/packages/app-store/rainbow/trpc/router.ts b/packages/app-store/rainbow/trpc/router.ts index d07374107d..81f4c49b4f 100644 --- a/packages/app-store/rainbow/trpc/router.ts +++ b/packages/app-store/rainbow/trpc/router.ts @@ -1,100 +1,53 @@ -import { ethers } from "ethers"; -import { configureChains, createClient } from "wagmi"; -import { z } from "zod"; - import { router, publicProcedure } from "@calcom/trpc/server/trpc"; -import abi from "../utils/abi.json"; -import { checkBalance, getProviders, SUPPORTED_CHAINS } from "../utils/ethereum"; +import { ZBalanceInputSchema, ZBalanceOutputSchema } from "./balance.schema"; +import { ZContractInputSchema, ZContractOutputSchema } from "./contract.schema"; + +interface EthRouterHandlersCache { + contract?: typeof import("./contract.handler").contractHandler; + balance?: typeof import("./balance.handler").balanceHandler; +} + +const UNSTABLE_HANDLER_CACHE: EthRouterHandlersCache = {}; const ethRouter = router({ // Fetch contract `name` and `symbol` or error contract: publicProcedure - .input( - z.object({ - address: z.string(), - chainId: z.number(), - }) - ) - .output( - z.object({ - data: z - .object({ - name: z.string(), - symbol: z.string(), - }) - .nullish(), - error: z.string().nullish(), - }) - ) + .input(ZContractInputSchema) + .output(ZContractOutputSchema) .query(async ({ input }) => { - const { address, chainId } = input; - const { provider } = configureChains( - SUPPORTED_CHAINS.filter((chain) => chain.id === chainId), - getProviders() - ); - - const client = createClient({ - provider, - }); - - const contract = new ethers.Contract(address, abi, client.provider); - - try { - const name = await contract.name(); - const symbol = await contract.symbol(); - - return { - data: { - name, - symbol: `$${symbol}`, - }, - }; - } catch (e) { - return { - data: { - name: address, - symbol: "$UNKNOWN", - }, - }; + if (!UNSTABLE_HANDLER_CACHE.contract) { + UNSTABLE_HANDLER_CACHE.contract = await import("./contract.handler").then( + (mod) => mod.contractHandler + ); } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.contract) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.contract({ + input, + }); }), // Fetch user's `balance` of either ERC-20 or ERC-721 compliant token or error balance: publicProcedure - .input( - z.object({ - address: z.string(), - tokenAddress: z.string(), - chainId: z.number(), - }) - ) - .output( - z.object({ - data: z - .object({ - hasBalance: z.boolean(), - }) - .nullish(), - error: z.string().nullish(), - }) - ) + .input(ZBalanceInputSchema) + .output(ZBalanceOutputSchema) .query(async ({ input }) => { - const { address, tokenAddress, chainId } = input; - try { - const hasBalance = await checkBalance(address, tokenAddress, chainId); - - return { - data: { - hasBalance, - }, - }; - } catch (e) { - return { - data: { - hasBalance: false, - }, - }; + if (!UNSTABLE_HANDLER_CACHE.balance) { + UNSTABLE_HANDLER_CACHE.balance = await import("./balance.handler").then((mod) => mod.balanceHandler); } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.balance) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.balance({ + input, + }); }), }); diff --git a/packages/features/bookings/components/event-meta/event.mock.ts b/packages/features/bookings/components/event-meta/event.mock.ts index 1b9e0cad9d..1bbff1ff57 100644 --- a/packages/features/bookings/components/event-meta/event.mock.ts +++ b/packages/features/bookings/components/event-meta/event.mock.ts @@ -1,4 +1,4 @@ -import { RouterOutputs } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; export const mockEvent: RouterOutputs["viewer"]["public"]["event"] = { id: 1, diff --git a/packages/features/bookings/layout/BookingLayout.tsx b/packages/features/bookings/layout/BookingLayout.tsx index fae8f86038..218ad8fd03 100644 --- a/packages/features/bookings/layout/BookingLayout.tsx +++ b/packages/features/bookings/layout/BookingLayout.tsx @@ -1,8 +1,9 @@ -import React, { ComponentProps } from "react"; +import type { ComponentProps } from "react"; +import React from "react"; import Shell from "@calcom/features/shell/Shell"; import { HorizontalTabs } from "@calcom/ui"; -import { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui"; +import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui"; import { FiltersContainer } from "../components/FiltersContainer"; diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 797cf1b6b9..9bae3030e9 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -14,7 +14,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { getTranslation } from "@calcom/lib/server/i18n"; -import prisma, { bookingMinimalSelect } from "@calcom/prisma"; +import { prisma, bookingMinimalSelect } from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; export const config = { diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 38cda62918..3e7d8ad1d8 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1,5 +1,5 @@ import type { Prisma } from "@prisma/client"; -import crypto from "crypto"; +import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; @@ -715,7 +715,7 @@ export const insightsRouter = router({ return { userId: booking.userId, user: userHashMap.get(booking.userId), - emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"), + emailMd5: md5(user?.email), count: booking._count.id, }; }); @@ -806,7 +806,7 @@ export const insightsRouter = router({ return { userId: booking.userId, user: userHashMap.get(booking.userId), - emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"), + emailMd5: md5(user?.email), count: booking._count.id, }; }); diff --git a/packages/features/schedules/lib/use-schedule/index.ts b/packages/features/schedules/lib/use-schedule/index.ts index ce81319e9c..2f9ab81a7b 100644 --- a/packages/features/schedules/lib/use-schedule/index.ts +++ b/packages/features/schedules/lib/use-schedule/index.ts @@ -1,4 +1,4 @@ export { useSchedule } from "./useSchedule"; export { useSlotsForDate } from "./useSlotsForDate"; export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays"; -export type { Slots } from "./types"; +export type { Slots } from "./types"; diff --git a/packages/features/schedules/lib/use-schedule/types.ts b/packages/features/schedules/lib/use-schedule/types.ts index 72ba9df508..c6f1aa8535 100644 --- a/packages/features/schedules/lib/use-schedule/types.ts +++ b/packages/features/schedules/lib/use-schedule/types.ts @@ -1,3 +1,3 @@ -import { RouterOutputs } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"]; diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 51e13837c7..511fc8e438 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -7,7 +7,8 @@ import { trpc } from "@calcom/trpc/react"; import { Meta, showToast, SkeletonContainer } from "@calcom/ui"; import { getLayout } from "../../settings/layouts/SettingsLayout"; -import WebhookForm, { WebhookFormSubmitData } from "../components/WebhookForm"; +import type { WebhookFormSubmitData } from "../components/WebhookForm"; +import WebhookForm from "../components/WebhookForm"; const querySchema = z.object({ id: z.string() }); diff --git a/packages/lib/perf.ts b/packages/lib/perf.ts new file mode 100644 index 0000000000..905d83ffa3 --- /dev/null +++ b/packages/lib/perf.ts @@ -0,0 +1,8 @@ +export const logP = (message: string) => { + const start = performance.now(); + + return () => { + const end = performance.now(); + console.log(`[PERF]: ${message} took ${end - start}ms`); + }; +}; diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index 065aa7d8aa..ea3b579f09 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -1,6 +1,7 @@ import type { NextPageContext } from "next/types"; import superjson from "superjson"; +import { httpBatchLink } from "../client/links/httpBatchLink"; import { httpLink } from "../client/links/httpLink"; import { loggerLink } from "../client/links/loggerLink"; import { splitLink } from "../client/links/splitLink"; @@ -8,7 +9,6 @@ import { createTRPCNext } from "../next"; // ℹ️ Type-only import: // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export import type { TRPCClientErrorLike } from "../react"; -import { httpBatchLink } from "../react"; import type { inferRouterInputs, inferRouterOutputs, Maybe } from "../server"; import type { AppRouter } from "../server/routers/_app"; diff --git a/packages/trpc/server/createContext.ts b/packages/trpc/server/createContext.ts index 6880ac9898..ed05bd238a 100644 --- a/packages/trpc/server/createContext.ts +++ b/packages/trpc/server/createContext.ts @@ -77,3 +77,5 @@ export const createContext = async ( res, }; }; + +export type TRPCContext = Awaited>; diff --git a/packages/trpc/server/routers/_app.ts b/packages/trpc/server/routers/_app.ts index f86d18bd4f..3c08010b90 100644 --- a/packages/trpc/server/routers/_app.ts +++ b/packages/trpc/server/routers/_app.ts @@ -2,7 +2,7 @@ * This file contains the root router of your tRPC-backend */ import { router } from "../trpc"; -import { viewerRouter } from "./viewer"; +import { viewerRouter } from "./viewer/_router"; /** * Create your application's root router diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx new file mode 100644 index 0000000000..c4d8414139 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -0,0 +1,369 @@ +import { authedProcedure, router } from "../../trpc"; +import { ZAppByIdInputSchema } from "./appById.schema"; +import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; +import { ZAppsInputSchema } from "./apps.schema"; +import { ZAwayInputSchema } from "./away.schema"; +import { ZDeleteCredentialInputSchema } from "./deleteCredential.schema"; +import { ZDeleteMeInputSchema } from "./deleteMe.schema"; +import { ZEventTypeOrderInputSchema } from "./eventTypeOrder.schema"; +import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema"; +import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; +import { ZIntegrationsInputSchema } from "./integrations.schema"; +import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; +import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema"; +import { ZUpdateProfileInputSchema } from "./updateProfile.schema"; +import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema"; + +type AppsRouterHandlerCache = { + me?: typeof import("./me.handler").meHandler; + avatar?: typeof import("./avatar.handler").avatarHandler; + deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler; + deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler; + away?: typeof import("./away.handler").awayHandler; + connectedCalendars?: typeof import("./connectedCalendars.handler").connectedCalendarsHandler; + setDestinationCalendar?: typeof import("./setDestinationCalendar.handler").setDestinationCalendarHandler; + integrations?: typeof import("./integrations.handler").integrationsHandler; + appById?: typeof import("./appById.handler").appByIdHandler; + apps?: typeof import("./apps.handler").appsHandler; + appCredentialsByType?: typeof import("./appCredentialsByType.handler").appCredentialsByTypeHandler; + stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler; + updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler; + eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler; + submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler; + locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler; + deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler; + bookingUnconfirmedCount?: typeof import("./bookingUnconfirmedCount.handler").bookingUnconfirmedCountHandler; + getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler; + getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler; + getUsersDefaultConferencingApp?: typeof import("./getUsersDefaultConferencingApp.handler").getUsersDefaultConferencingAppHandler; + updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; + +export const loggedInViewerRouter = router({ + me: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.me) { + UNSTABLE_HANDLER_CACHE.me = (await import("./me.handler")).meHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.me) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.me({ ctx }); + }), + + avatar: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.avatar) { + UNSTABLE_HANDLER_CACHE.avatar = (await import("./avatar.handler")).avatarHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.avatar) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.avatar({ ctx }); + }), + + deleteMe: authedProcedure.input(ZDeleteMeInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteMe) { + UNSTABLE_HANDLER_CACHE.deleteMe = (await import("./deleteMe.handler")).deleteMeHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteMe) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteMe({ ctx, input }); + }), + + deleteMeWithoutPassword: authedProcedure.mutation(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) { + UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword = ( + await import("./deleteMeWithoutPassword.handler") + ).deleteMeWithoutPasswordHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword({ ctx }); + }), + + away: authedProcedure.input(ZAwayInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.away) { + UNSTABLE_HANDLER_CACHE.away = (await import("./away.handler")).awayHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.away) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.away({ ctx, input }); + }), + + connectedCalendars: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) { + UNSTABLE_HANDLER_CACHE.connectedCalendars = ( + await import("./connectedCalendars.handler") + ).connectedCalendarsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.connectedCalendars({ ctx }); + }), + + setDestinationCalendar: authedProcedure + .input(ZSetDestinationCalendarInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) { + UNSTABLE_HANDLER_CACHE.setDestinationCalendar = ( + await import("./setDestinationCalendar.handler") + ).setDestinationCalendarHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.setDestinationCalendar({ ctx, input }); + }), + + integrations: authedProcedure.input(ZIntegrationsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.integrations) { + UNSTABLE_HANDLER_CACHE.integrations = (await import("./integrations.handler")).integrationsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.integrations) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.integrations({ ctx, input }); + }), + + appById: authedProcedure.input(ZAppByIdInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.appById) { + UNSTABLE_HANDLER_CACHE.appById = (await import("./appById.handler")).appByIdHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.appById) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.appById({ ctx, input }); + }), + + apps: authedProcedure.input(ZAppsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.apps) { + UNSTABLE_HANDLER_CACHE.apps = (await import("./apps.handler")).appsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.apps) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.apps({ ctx, input }); + }), + + appCredentialsByType: authedProcedure + .input(ZAppCredentialsByTypeInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) { + UNSTABLE_HANDLER_CACHE.appCredentialsByType = ( + await import("./appCredentialsByType.handler") + ).appCredentialsByTypeHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.appCredentialsByType({ ctx, input }); + }), + + stripeCustomer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) { + UNSTABLE_HANDLER_CACHE.stripeCustomer = ( + await import("./stripeCustomer.handler") + ).stripeCustomerHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.stripeCustomer({ ctx }); + }), + + updateProfile: authedProcedure.input(ZUpdateProfileInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateProfile) { + UNSTABLE_HANDLER_CACHE.updateProfile = (await import("./updateProfile.handler")).updateProfileHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateProfile) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateProfile({ ctx, input }); + }), + + eventTypeOrder: authedProcedure.input(ZEventTypeOrderInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) { + UNSTABLE_HANDLER_CACHE.eventTypeOrder = ( + await import("./eventTypeOrder.handler") + ).eventTypeOrderHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input }); + }), + + //Comment for PR: eventTypePosition is not used anywhere + submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.submitFeedback) { + UNSTABLE_HANDLER_CACHE.submitFeedback = ( + await import("./submitFeedback.handler") + ).submitFeedbackHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.submitFeedback) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.submitFeedback({ ctx, input }); + }), + + locationOptions: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.locationOptions) { + UNSTABLE_HANDLER_CACHE.locationOptions = ( + await import("./locationOptions.handler") + ).locationOptionsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.locationOptions) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.locationOptions({ ctx }); + }), + + deleteCredential: authedProcedure.input(ZDeleteCredentialInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteCredential) { + UNSTABLE_HANDLER_CACHE.deleteCredential = ( + await import("./deleteCredential.handler") + ).deleteCredentialHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteCredential) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deleteCredential({ ctx, input }); + }), + + bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) { + UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount = ( + await import("./bookingUnconfirmedCount.handler") + ).bookingUnconfirmedCountHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount({ ctx }); + }), + + getCalVideoRecordings: authedProcedure + .input(ZGetCalVideoRecordingsInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) { + UNSTABLE_HANDLER_CACHE.getCalVideoRecordings = ( + await import("./getCalVideoRecordings.handler") + ).getCalVideoRecordingsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getCalVideoRecordings({ ctx, input }); + }), + + getDownloadLinkOfCalVideoRecordings: authedProcedure + .input(ZGetDownloadLinkOfCalVideoRecordingsInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) { + UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings = ( + await import("./getDownloadLinkOfCalVideoRecordings.handler") + ).getDownloadLinkOfCalVideoRecordingsHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings({ ctx, input }); + }), + + getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) { + UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp = ( + await import("./getUsersDefaultConferencingApp.handler") + ).getUsersDefaultConferencingAppHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp({ ctx }); + }), + + updateUserDefaultConferencingApp: authedProcedure + .input(ZUpdateUserDefaultConferencingAppInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) { + UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp = ( + await import("./updateUserDefaultConferencingApp.handler") + ).updateUserDefaultConferencingAppHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp({ ctx, input }); + }), +}); diff --git a/packages/trpc/server/routers/loggedInViewer/appById.handler.ts b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts new file mode 100644 index 0000000000..3b154e79f8 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts @@ -0,0 +1,31 @@ +import getApps from "@calcom/app-store/utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TAppByIdInputSchema } from "./appById.schema"; + +type AppByIdOptions = { + ctx: { + user: NonNullable; + }; + input: TAppByIdInputSchema; +}; + +export const appByIdHandler = async ({ ctx, input }: AppByIdOptions) => { + const { user } = ctx; + const appId = input.appId; + const { credentials } = user; + const apps = getApps(credentials); + const appFromDb = apps.find((app) => app.slug === appId); + if (!appFromDb) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { credential: _, credentials: _1, ...app } = appFromDb; + return { + isInstalled: appFromDb.credentials.length, + ...app, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/appById.schema.ts b/packages/trpc/server/routers/loggedInViewer/appById.schema.ts new file mode 100644 index 0000000000..f02610cd76 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appById.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppByIdInputSchema = z.object({ + appId: z.string(), +}); + +export type TAppByIdInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts new file mode 100644 index 0000000000..6d5f4e2ee9 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -0,0 +1,15 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; + +type AppCredentialsByTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TAppCredentialsByTypeInputSchema; +}; + +export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => { + const { user } = ctx; + return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts new file mode 100644 index 0000000000..283997a5a9 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppCredentialsByTypeInputSchema = z.object({ + appType: z.string(), +}); + +export type TAppCredentialsByTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.handler.ts b/packages/trpc/server/routers/loggedInViewer/apps.handler.ts new file mode 100644 index 0000000000..85e510009a --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/apps.handler.ts @@ -0,0 +1,24 @@ +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAppsInputSchema } from "./apps.schema"; + +type AppsOptions = { + ctx: { + user: NonNullable; + }; + input: TAppsInputSchema; +}; + +export const appsHandler = async ({ ctx, input }: AppsOptions) => { + const { user } = ctx; + const { credentials } = user; + + const apps = await getEnabledApps(credentials); + return apps + .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) + .map((app) => ({ + ...app, + isInstalled: !!app.credentials?.length, + })); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.schema.ts b/packages/trpc/server/routers/loggedInViewer/apps.schema.ts new file mode 100644 index 0000000000..3ab2115e8d --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/apps.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAppsInputSchema = z.object({ + extendsFeature: z.literal("EventType"), +}); + +export type TAppsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts b/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts new file mode 100644 index 0000000000..8a4f0ee74d --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/avatar.handler.ts @@ -0,0 +1,13 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type AvatarOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const avatarHandler = async ({ ctx }: AvatarOptions) => { + return { + avatar: ctx.user.rawAvatar, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts b/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/away.handler.ts b/packages/trpc/server/routers/loggedInViewer/away.handler.ts new file mode 100644 index 0000000000..b9787cedf5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/away.handler.ts @@ -0,0 +1,22 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAwayInputSchema } from "./away.schema"; + +type AwayOptions = { + ctx: { + user: NonNullable; + }; + input: TAwayInputSchema; +}; + +export const awayHandler = async ({ ctx, input }: AwayOptions) => { + await prisma.user.update({ + where: { + email: ctx.user.email, + }, + data: { + away: input.away, + }, + }); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/away.schema.ts b/packages/trpc/server/routers/loggedInViewer/away.schema.ts new file mode 100644 index 0000000000..da84660332 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/away.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAwayInputSchema = z.object({ + away: z.boolean(), +}); + +export type TAwayInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts new file mode 100644 index 0000000000..5a0e96b9cd --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.handler.ts @@ -0,0 +1,38 @@ +import { BookingStatus } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type BookingUnconfirmedCountOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const bookingUnconfirmedCountHandler = async ({ ctx }: BookingUnconfirmedCountOptions) => { + const { user } = ctx; + const count = await prisma.booking.count({ + where: { + status: BookingStatus.PENDING, + userId: user.id, + endTime: { gt: new Date() }, + }, + }); + const recurringGrouping = await prisma.booking.groupBy({ + by: ["recurringEventId"], + _count: { + recurringEventId: true, + }, + where: { + recurringEventId: { not: { equals: null } }, + status: { equals: "PENDING" }, + userId: user.id, + endTime: { gt: new Date() }, + }, + }); + return recurringGrouping.reduce((prev, current) => { + // recurringEventId is the total number of recurring instances for a booking + // we need to subtract all but one, to represent a single recurring booking + return prev - (current._count?.recurringEventId - 1); + }, count); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/bookingUnconfirmedCount.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts new file mode 100644 index 0000000000..3a79cf95dc --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.handler.ts @@ -0,0 +1,90 @@ +import type { DestinationCalendar } from "@prisma/client"; +import { AppCategories } from "@prisma/client"; + +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type ConnectedCalendarsOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const connectedCalendarsHandler = async ({ ctx }: ConnectedCalendarsOptions) => { + const { user } = ctx; + + const userCredentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + app: { + categories: { has: AppCategories.calendar }, + enabled: true, + }, + }, + }); + + // get user's credentials + their connected integrations + const calendarCredentials = getCalendarCredentials(userCredentials); + + // get all the connected integrations' calendars (from third party) + const { connectedCalendars, destinationCalendar } = await getConnectedCalendars( + calendarCredentials, + user.selectedCalendars, + user.destinationCalendar?.externalId + ); + + if (connectedCalendars.length === 0) { + /* As there are no connected calendars, delete the destination calendar if it exists */ + if (user.destinationCalendar) { + await prisma.destinationCalendar.delete({ + where: { userId: user.id }, + }); + user.destinationCalendar = null; + } + } else if (!user.destinationCalendar) { + /* + There are connected calendars, but no destination calendar + So create a default destination calendar with the first primary connected calendar + */ + const { integration = "", externalId = "", credentialId } = connectedCalendars[0].primary ?? {}; + user.destinationCalendar = await prisma.destinationCalendar.create({ + data: { + userId: user.id, + integration, + externalId, + credentialId, + }, + }); + } else { + /* There are connected calendars and a destination calendar */ + + // Check if destinationCalendar exists in connectedCalendars + const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); + const destinationCal = allCals.find( + (cal) => + cal.externalId === user.destinationCalendar?.externalId && + cal.integration === user.destinationCalendar?.integration + ); + + if (!destinationCal) { + // If destinationCalendar is out of date, update it with the first primary connected calendar + const { integration = "", externalId = "" } = connectedCalendars[0].primary ?? {}; + user.destinationCalendar = await prisma.destinationCalendar.update({ + where: { userId: user.id }, + data: { + integration, + externalId, + }, + }); + } + } + + return { + connectedCalendars, + destinationCalendar: { + ...(user.destinationCalendar as DestinationCalendar), + ...destinationCalendar, + }, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectedCalendars.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts new file mode 100644 index 0000000000..08c6fffc3e --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -0,0 +1,345 @@ +import { AppCategories, BookingStatus } from "@prisma/client"; +import z from "zod"; + +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; +import { DailyLocationType } from "@calcom/core/location"; +import { sendCancelledEmails } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import getPaymentAppData from "@calcom/lib/getPaymentAppData"; +import { deletePayment } from "@calcom/lib/payment/deletePayment"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { bookingMinimalSelect } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TDeleteCredentialInputSchema } from "./deleteCredential.schema"; + +type DeleteCredentialOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteCredentialInputSchema; +}; + +export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOptions) => { + const { id, externalId } = input; + + const credential = await prisma.credential.findFirst({ + where: { + id: id, + userId: ctx.user.id, + }, + select: { + key: true, + appId: true, + app: { + select: { + slug: true, + categories: true, + dirName: true, + }, + }, + }, + }); + + if (!credential) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + }, + select: { + id: true, + locations: true, + destinationCalendar: { + include: { + credential: true, + }, + }, + price: true, + currency: true, + metadata: true, + }, + }); + + // TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data. + for (const eventType of eventTypes) { + if (eventType.locations) { + // If it's a video, replace the location with Cal video + if (credential.app?.categories.includes(AppCategories.video)) { + // Find the user's event types + + // Look for integration name from app slug + const integrationQuery = + credential.app?.slug === "msteams" ? "office365_video" : credential.app?.slug.split("-")[0]; + + // Check if the event type uses the deleted integration + + // To avoid type errors, need to stringify and parse JSON to use array methods + const locationsSchema = z.array(z.object({ type: z.string() })); + const locations = locationsSchema.parse(eventType.locations); + + const updatedLocations = locations.map((location: { type: string }) => { + if (location.type.includes(integrationQuery)) { + return { type: DailyLocationType }; + } + return location; + }); + + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + locations: updatedLocations, + }, + }); + } + } + + // If it's a calendar, remove the destination calendar from the event type + if (credential.app?.categories.includes(AppCategories.calendar)) { + if (eventType.destinationCalendar?.credential?.appId === credential.appId) { + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + id: eventType.destinationCalendar?.id, + }, + }); + if (destinationCalendar) { + await prisma.destinationCalendar.delete({ + where: { + id: destinationCalendar.id, + }, + }); + } + } + + if (externalId) { + const existingSelectedCalendar = await prisma.selectedCalendar.findFirst({ + where: { + externalId: externalId, + }, + }); + // @TODO: SelectedCalendar doesn't have unique ID so we should only delete one item + if (existingSelectedCalendar) { + await prisma.selectedCalendar.delete({ + where: { + userId_integration_externalId: { + userId: existingSelectedCalendar.userId, + externalId: existingSelectedCalendar.externalId, + integration: existingSelectedCalendar.integration, + }, + }, + }); + } + } + } + + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + + const stripeAppData = getPaymentAppData({ ...eventType, metadata }); + + // If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings + if (credential.app?.categories.includes(AppCategories.payment)) { + if (stripeAppData.price) { + await prisma.$transaction(async () => { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + hidden: true, + metadata: { + ...metadata, + apps: { + ...metadata?.apps, + stripe: { + ...metadata?.apps?.stripe, + price: 0, + }, + }, + }, + }, + }); + + // Assuming that all bookings under this eventType need to be paid + const unpaidBookings = await prisma.booking.findMany({ + where: { + userId: ctx.user.id, + eventTypeId: eventType.id, + status: "PENDING", + paid: false, + payment: { + every: { + success: false, + }, + }, + }, + select: { + ...bookingMinimalSelect, + recurringEventId: true, + userId: true, + responses: true, + user: { + select: { + id: true, + credentials: true, + email: true, + timeZone: true, + name: true, + destinationCalendar: true, + locale: true, + }, + }, + location: true, + references: { + select: { + uid: true, + type: true, + externalCalendarId: true, + }, + }, + payment: true, + paid: true, + eventType: { + select: { + recurringEvent: true, + title: true, + bookingFields: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + }, + }, + uid: true, + eventTypeId: true, + destinationCalendar: true, + }, + }); + + for (const booking of unpaidBookings) { + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + status: BookingStatus.CANCELLED, + cancellationReason: "Payment method removed", + }, + }); + + for (const payment of booking.payment) { + try { + await deletePayment(payment.id, credential); + } catch (e) { + console.error(e); + } + await prisma.payment.delete({ + where: { + id: payment.id, + }, + }); + } + + await prisma.attendee.deleteMany({ + where: { + bookingId: booking.id, + }, + }); + + await prisma.bookingReference.deleteMany({ + where: { + bookingId: booking.id, + }, + }); + + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common"); + await sendCancelledEmails({ + type: booking?.eventType?.title as string, + title: booking.title, + description: booking.description, + customInputs: isPrismaObjOrUndefined(booking.customInputs), + ...getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + booking, + }), + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: booking?.user?.email as string, + name: booking?.user?.name ?? "Nameless", + timeZone: booking?.user?.timeZone as string, + language: { translate: tOrganizer, locale: booking?.user?.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + location: booking.location, + destinationCalendar: booking.destinationCalendar || booking.user?.destinationCalendar, + cancellationReason: "Payment method removed by organizer", + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, + }); + } + }); + } + } + } + + // if zapier get disconnected, delete zapier apiKey, delete zapier webhooks and cancel all scheduled jobs from zapier + if (credential.app?.slug === "zapier") { + await prisma.apiKey.deleteMany({ + where: { + userId: ctx.user.id, + appId: "zapier", + }, + }); + await prisma.webhook.deleteMany({ + where: { + userId: ctx.user.id, + appId: "zapier", + }, + }); + const bookingsWithScheduledJobs = await prisma.booking.findMany({ + where: { + userId: ctx.user.id, + scheduledJobs: { + isEmpty: false, + }, + }, + }); + for (const booking of bookingsWithScheduledJobs) { + cancelScheduledJobs(booking, credential.appId); + } + } + + // Validated that credential is user's above + await prisma.credential.delete({ + where: { + id: id, + }, + }); + // Revalidate user calendar cache. + if (credential.app?.slug.includes("calendar")) { + await fetch(`${WEBAPP_URL}/api/revalidate-calendar-cache/${ctx?.user?.username}`); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts new file mode 100644 index 0000000000..8814240beb --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteCredentialInputSchema = z.object({ + id: z.number(), + externalId: z.string().optional(), +}); + +export type TDeleteCredentialInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts new file mode 100644 index 0000000000..3dac93e029 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts @@ -0,0 +1,87 @@ +import { IdentityProvider } from "@prisma/client"; +import { authenticator } from "otplib"; + +import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteMeInputSchema } from "./deleteMe.schema"; + +type DeleteMeOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteMeInputSchema; +}; + +export const deleteMeHandler = async ({ ctx, input }: DeleteMeOptions) => { + // Check if input.password is correct + const user = await prisma.user.findUnique({ + where: { + email: ctx.user.email.toLowerCase(), + }, + }); + if (!user) { + throw new Error(ErrorCode.UserNotFound); + } + + if (user.identityProvider !== IdentityProvider.CAL) { + throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); + } + + if (!user.password) { + throw new Error(ErrorCode.UserMissingPassword); + } + + const isCorrectPassword = await verifyPassword(input.password, user.password); + if (!isCorrectPassword) { + throw new Error(ErrorCode.IncorrectPassword); + } + + if (user.twoFactorEnabled) { + if (!input.totpCode) { + throw new Error(ErrorCode.SecondFactorRequired); + } + + if (!user.twoFactorSecret) { + console.error(`Two factor is enabled for user ${user.id} but they have no secret`); + throw new Error(ErrorCode.InternalServerError); + } + + if (!process.env.CALENDSO_ENCRYPTION_KEY) { + console.error(`"Missing encryption key; cannot proceed with two factor login."`); + throw new Error(ErrorCode.InternalServerError); + } + + const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY); + if (secret.length !== 32) { + console.error( + `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}` + ); + throw new Error(ErrorCode.InternalServerError); + } + + // If user has 2fa enabled, check if input.totpCode is correct + const isValidToken = authenticator.check(input.totpCode, secret); + if (!isValidToken) { + throw new Error(ErrorCode.IncorrectTwoFactorCode); + } + } + + // If 2FA is disabled or totpCode is valid then delete the user from stripe and database + await deleteStripeCustomer(user).catch(console.warn); + // Remove my account + const deletedUser = await prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + + // Sync Services + syncServicesDeleteWebUser(deletedUser); + return; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts new file mode 100644 index 0000000000..1f26c19049 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMe.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteMeInputSchema = z.object({ + password: z.string(), + totpCode: z.string().optional(), +}); + +export type TDeleteMeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts new file mode 100644 index 0000000000..55b13ce44b --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.handler.ts @@ -0,0 +1,46 @@ +import { IdentityProvider } from "@prisma/client"; + +import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type DeleteMeWithoutPasswordOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const deleteMeWithoutPasswordHandler = async ({ ctx }: DeleteMeWithoutPasswordOptions) => { + const user = await prisma.user.findUnique({ + where: { + email: ctx.user.email.toLowerCase(), + }, + }); + if (!user) { + throw new Error(ErrorCode.UserNotFound); + } + + if (user.identityProvider === IdentityProvider.CAL) { + throw new Error(ErrorCode.SocialIdentityProviderRequired); + } + + if (user.twoFactorEnabled) { + throw new Error(ErrorCode.SocialIdentityProviderRequired); + } + + // Remove me from Stripe + await deleteStripeCustomer(user).catch(console.warn); + + // Remove my account + const deletedUser = await prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + // Sync Services + syncServicesDeleteWebUser(deletedUser); + + return; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/deleteMeWithoutPassword.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts new file mode 100644 index 0000000000..6f9dd4ea62 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.handler.ts @@ -0,0 +1,69 @@ +import { reverse } from "lodash"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TEventTypeOrderInputSchema } from "./eventTypeOrder.schema"; + +type EventTypeOrderOptions = { + ctx: { + user: NonNullable; + }; + input: TEventTypeOrderInputSchema; +}; + +export const eventTypeOrderHandler = async ({ ctx, input }: EventTypeOrderOptions) => { + const { user } = ctx; + + const allEventTypes = await prisma.eventType.findMany({ + select: { + id: true, + }, + where: { + id: { + in: input.ids, + }, + OR: [ + { + userId: user.id, + }, + { + users: { + some: { + id: user.id, + }, + }, + }, + { + team: { + members: { + some: { + userId: user.id, + }, + }, + }, + }, + ], + }, + }); + const allEventTypeIds = new Set(allEventTypes.map((type) => type.id)); + if (input.ids.some((id) => !allEventTypeIds.has(id))) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + await Promise.all( + reverse(input.ids).map((id, position) => { + return prisma.eventType.update({ + where: { + id, + }, + data: { + position, + }, + }); + }) + ); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts new file mode 100644 index 0000000000..29faed3e47 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/eventTypeOrder.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZEventTypeOrderInputSchema = z.object({ + ids: z.array(z.number()), +}); + +export type TEventTypeOrderInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts new file mode 100644 index 0000000000..4fac9db381 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.handler.ts @@ -0,0 +1,26 @@ +import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema"; + +type GetCalVideoRecordingsOptions = { + ctx: { + user: NonNullable; + }; + input: TGetCalVideoRecordingsInputSchema; +}; + +export const getCalVideoRecordingsHandler = async ({ ctx: _ctx, input }: GetCalVideoRecordingsOptions) => { + const { roomName } = input; + + try { + const res = await getRecordingsOfCalVideoByRoomName(roomName); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts new file mode 100644 index 0000000000..e151d05402 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getCalVideoRecordings.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetCalVideoRecordingsInputSchema = z.object({ + roomName: z.string(), +}); + +export type TGetCalVideoRecordingsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts new file mode 100644 index 0000000000..360184e605 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.handler.ts @@ -0,0 +1,38 @@ +/// +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; + +import { TRPCError } from "@trpc/server"; + +import type { CreateInnerContextOptions } from "../../createContext"; +import type { TGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; + +type GetDownloadLinkOfCalVideoRecordingsHandlerOptions = { + ctx: CreateInnerContextOptions; + input: TGetDownloadLinkOfCalVideoRecordingsInputSchema; +}; + +export const getDownloadLinkOfCalVideoRecordingsHandler = async ({ + input, + ctx, +}: GetDownloadLinkOfCalVideoRecordingsHandlerOptions) => { + const { recordingId } = input; + const { session } = ctx; + + const isDownloadAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam; + + if (!isDownloadAllowed) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + try { + const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts new file mode 100644 index 0000000000..fabb8d6af4 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getDownloadLinkOfCalVideoRecordings.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZGetDownloadLinkOfCalVideoRecordingsInputSchema = z.object({ + recordingId: z.string(), +}); + +export type TGetDownloadLinkOfCalVideoRecordingsInputSchema = z.infer< + typeof ZGetDownloadLinkOfCalVideoRecordingsInputSchema +>; diff --git a/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts new file mode 100644 index 0000000000..bfe662c1fc --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.handler.ts @@ -0,0 +1,14 @@ +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type GetUsersDefaultConferencingAppOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getUsersDefaultConferencingAppHandler = async ({ + ctx, +}: GetUsersDefaultConferencingAppOptions) => { + return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/getUsersDefaultConferencingApp.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts new file mode 100644 index 0000000000..e312c9b2d7 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -0,0 +1,52 @@ +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TIntegrationsInputSchema } from "./integrations.schema"; + +type IntegrationsOptions = { + ctx: { + user: NonNullable; + }; + input: TIntegrationsInputSchema; +}; + +export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) => { + const { user } = ctx; + const { variant, exclude, onlyInstalled } = input; + const { credentials } = user; + + const enabledApps = await getEnabledApps(credentials); + //TODO: Refactor this to pick up only needed fields and prevent more leaking + let apps = enabledApps.map( + ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { + const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); + const invalidCredentialIds = credentials + .filter((c) => c.type === app.type && c.invalid) + .map((c) => c.id); + return { + ...app, + credentialIds, + invalidCredentialIds, + }; + } + ); + + if (variant) { + // `flatMap()` these work like `.filter()` but infers the types correctly + apps = apps + // variant check + .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); + } + + if (exclude) { + // exclusion filter + apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); + } + + if (onlyInstalled) { + apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); + } + return { + items: apps, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts new file mode 100644 index 0000000000..19ac998e71 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZIntegrationsInputSchema = z.object({ + variant: z.string().optional(), + exclude: z.array(z.string()).optional(), + onlyInstalled: z.boolean().optional(), +}); + +export type TIntegrationsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts new file mode 100644 index 0000000000..4a09416c3e --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts @@ -0,0 +1,35 @@ +import { getLocationGroupedOptions } from "@calcom/app-store/utils"; +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type LocationOptionsOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const locationOptionsHandler = async ({ ctx }: LocationOptionsOptions) => { + const credentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + appId: true, + invalid: true, + }, + }); + + const integrations = await getEnabledApps(credentials); + + const t = await getTranslation(ctx.user.locale ?? "en", "common"); + + const locationOptions = getLocationGroupedOptions(integrations, t); + + return locationOptions; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts new file mode 100644 index 0000000000..be958a5e43 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -0,0 +1,41 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type MeOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const meHandler = async ({ ctx }: MeOptions) => { + const { user } = ctx; + // Destructuring here only makes it more illegible + // pick only the part we want to expose in the API + return { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + startTime: user.startTime, + endTime: user.endTime, + bufferTime: user.bufferTime, + locale: user.locale, + timeFormat: user.timeFormat, + timeZone: user.timeZone, + avatar: user.avatar, + createdDate: user.createdDate, + trialEndsAt: user.trialEndsAt, + defaultScheduleId: user.defaultScheduleId, + completedOnboarding: user.completedOnboarding, + twoFactorEnabled: user.twoFactorEnabled, + disableImpersonation: user.disableImpersonation, + identityProvider: user.identityProvider, + brandColor: user.brandColor, + darkBrandColor: user.darkBrandColor, + away: user.away, + bio: user.bio, + weekStart: user.weekStart, + theme: user.theme, + hideBranding: user.hideBranding, + metadata: user.metadata, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/me.schema.ts b/packages/trpc/server/routers/loggedInViewer/me.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/me.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts new file mode 100644 index 0000000000..55343b4f32 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts @@ -0,0 +1,65 @@ +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; + +type SetDestinationCalendarOptions = { + ctx: { + user: NonNullable; + }; + input: TSetDestinationCalendarInputSchema; +}; + +export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinationCalendarOptions) => { + const { user } = ctx; + const { integration, externalId, eventTypeId } = input; + const calendarCredentials = getCalendarCredentials(user.credentials); + const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); + const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); + + const credentialId = allCals.find( + (cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false + )?.credentialId; + + if (!credentialId) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` }); + } + + let where; + + if (eventTypeId) { + if ( + !(await prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId: user.id, + }, + })) + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `You don't have access to event type ${eventTypeId}`, + }); + } + + where = { eventTypeId }; + } else where = { userId: user.id }; + + await prisma.destinationCalendar.upsert({ + where, + update: { + integration, + externalId, + credentialId, + }, + create: { + ...where, + integration, + externalId, + credentialId, + }, + }); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts new file mode 100644 index 0000000000..8741b05869 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZSetDestinationCalendarInputSchema = z.object({ + integration: z.string(), + externalId: z.string(), + eventTypeId: z.number().nullish(), + bookingId: z.number().nullish(), +}); + +export type TSetDestinationCalendarInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts new file mode 100644 index 0000000000..857e865387 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts @@ -0,0 +1,50 @@ +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +type StripeCustomerOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => { + const { + user: { id: userId }, + } = ctx; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + metadata: true, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" }); + } + + const metadata = userMetadata.parse(user.metadata); + + if (!metadata?.stripeCustomerId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer id" }); + } + // Fetch stripe customer + const stripeCustomerId = metadata?.stripeCustomerId; + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted) { + throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" }); + } + + const username = customer?.metadata?.username || null; + + return { + isPremium: !!metadata?.isPremium, + username, + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts new file mode 100644 index 0000000000..27db135abb --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts @@ -0,0 +1,35 @@ +import dayjs from "@calcom/dayjs"; +import { sendFeedbackEmail } from "@calcom/emails"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TSubmitFeedbackInputSchema } from "./submitFeedback.schema"; + +type SubmitFeedbackOptions = { + ctx: { + user: NonNullable; + }; + input: TSubmitFeedbackInputSchema; +}; + +export const submitFeedbackHandler = async ({ ctx, input }: SubmitFeedbackOptions) => { + const { rating, comment } = input; + + const feedback = { + username: ctx.user.username || "Nameless", + email: ctx.user.email || "No email address", + rating: rating, + comment: comment, + }; + + await prisma.feedback.create({ + data: { + date: dayjs().toISOString(), + userId: ctx.user.id, + rating: rating, + comment: comment, + }, + }); + + if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts b/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts new file mode 100644 index 0000000000..f56091c1ab --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/submitFeedback.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZSubmitFeedbackInputSchema = z.object({ + rating: z.string(), + comment: z.string(), +}); + +export type TSubmitFeedbackInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts new file mode 100644 index 0000000000..7d9debf1e4 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -0,0 +1,140 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiResponse, GetServerSidePropsContext } from "next"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; +import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; +import slugify from "@calcom/lib/slugify"; +import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateProfileInputSchema } from "./updateProfile.schema"; + +type UpdateProfileOptions = { + ctx: { + user: NonNullable; + res?: NextApiResponse | GetServerSidePropsContext["res"]; + }; + input: TUpdateProfileInputSchema; +}; + +export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => { + const { user } = ctx; + const data: Prisma.UserUpdateInput = { + ...input, + metadata: input.metadata as Prisma.InputJsonValue, + }; + let isPremiumUsername = false; + if (input.username) { + const username = slugify(input.username); + // Only validate if we're changing usernames + if (username !== user.username) { + data.username = username; + const response = await checkUsername(username); + isPremiumUsername = response.premium; + if (!response.available) { + throw new TRPCError({ code: "BAD_REQUEST", message: response.message }); + } + } + } + if (input.avatar) { + data.avatar = await resizeBase64Image(input.avatar); + } + const userToUpdate = await prisma.user.findUnique({ + where: { + id: user.id, + }, + }); + + if (!userToUpdate) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + const metadata = userMetadata.parse(userToUpdate.metadata); + + const isPremium = metadata?.isPremium; + if (isPremiumUsername) { + const stripeCustomerId = metadata?.stripeCustomerId; + if (!isPremium || !stripeCustomerId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" }); + } + + const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId }); + + if (!stripeSubscriptions || !stripeSubscriptions.data.length) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No stripeSubscription found", + }); + } + + // Iterate over subscriptions and look for premium product id and status active + // @TODO: iterate if stripeSubscriptions.hasMore is true + const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some( + (subscription) => + subscription.items.data[0].price.product === getPremiumPlanProductId() && + subscription.status === "active" + ); + + if (!isPremiumUsernameSubscriptionActive) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You need to pay for premium username", + }); + } + } + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data, + select: { + id: true, + username: true, + email: true, + metadata: true, + name: true, + createdDate: true, + }, + }); + + // Sync Services + await syncServicesUpdateWebUser(updatedUser); + + // Notify stripe about the change + if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) { + const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`; + await stripe.customers.update(stripeCustomerId, { + metadata: { + username: updatedUser.username, + email: updatedUser.email, + userId: updatedUser.id, + }, + }); + } + // Revalidate booking pages + const res = ctx.res as NextApiResponse; + if (typeof res?.revalidate !== "undefined") { + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: user.id, + team: null, + hidden: false, + }, + select: { + id: true, + slug: true, + }, + }); + // waiting for this isn't needed + Promise.all(eventTypes.map((eventType) => res?.revalidate(`/${ctx.user.username}/${eventType.slug}`))) + .then(() => console.info("Booking pages revalidated")) + .catch((e) => console.error(e)); + } +}; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts new file mode 100644 index 0000000000..7a3f1f8f46 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { userMetadata } from "@calcom/prisma/zod-utils"; + +export const ZUpdateProfileInputSchema = z.object({ + username: z.string().optional(), + name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).optional(), + email: z.string().optional(), + bio: z.string().optional(), + avatar: z.string().optional(), + timeZone: z.string().optional(), + weekStart: z.string().optional(), + hideBranding: z.boolean().optional(), + allowDynamicBooking: z.boolean().optional(), + brandColor: z.string().optional(), + darkBrandColor: z.string().optional(), + theme: z.string().optional().nullable(), + completedOnboarding: z.boolean().optional(), + locale: z.string().optional(), + timeFormat: z.number().optional(), + disableImpersonation: z.boolean().optional(), + metadata: userMetadata.optional(), +}); + +export type TUpdateProfileInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts new file mode 100644 index 0000000000..a34ba812d6 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts @@ -0,0 +1,59 @@ +import z from "zod"; + +import getApps from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema"; + +type UpdateUserDefaultConferencingAppOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateUserDefaultConferencingAppInputSchema; +}; + +export const updateUserDefaultConferencingAppHandler = async ({ + ctx, + input, +}: UpdateUserDefaultConferencingAppOptions) => { + const currentMetadata = userMetadata.parse(ctx.user.metadata); + const credentials = ctx.user.credentials; + const foundApp = getApps(credentials).filter((app) => app.slug === input.appSlug)[0]; + const appLocation = foundApp?.appData?.location; + + if (!foundApp || !appLocation) throw new TRPCError({ code: "BAD_REQUEST", message: "App not installed" }); + + if (appLocation.linkType === "static" && !input.appLink) { + throw new TRPCError({ code: "BAD_REQUEST", message: "App link is required" }); + } + + if (appLocation.linkType === "static" && appLocation.urlRegExp) { + const validLink = z + .string() + .regex(new RegExp(appLocation.urlRegExp), "Invalid App Link") + .parse(input.appLink); + if (!validLink) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid app link" }); + } + } + + await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + metadata: { + ...currentMetadata, + defaultConferencingApp: { + appSlug: input.appSlug, + appLink: input.appLink, + }, + }, + }, + }); + return input; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts new file mode 100644 index 0000000000..31b7a10179 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZUpdateUserDefaultConferencingAppInputSchema = z.object({ + appSlug: z.string().optional(), + appLink: z.string().optional(), +}); + +export type TUpdateUserDefaultConferencingAppInputSchema = z.infer< + typeof ZUpdateUserDefaultConferencingAppInputSchema +>; diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx new file mode 100644 index 0000000000..6db1cca597 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -0,0 +1,140 @@ +import { publicProcedure, router } from "../../trpc"; +import { slotsRouter } from "../viewer/slots/_router"; +import { ZEventInputSchema } from "./event.schema"; +import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; +import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; + +type PublicViewerRouterHandlerCache = { + session?: typeof import("./session.handler").sessionHandler; + i18n?: typeof import("./i18n.handler").i18nHandler; + countryCode?: typeof import("./countryCode.handler").countryCodeHandler; + samlTenantProduct?: typeof import("./samlTenantProduct.handler").samlTenantProductHandler; + stripeCheckoutSession?: typeof import("./stripeCheckoutSession.handler").stripeCheckoutSessionHandler; + cityTimezones?: typeof import("./cityTimezones.handler").cityTimezonesHandler; + event?: typeof import("./event.handler").eventHandler; +}; + +const UNSTABLE_HANDLER_CACHE: PublicViewerRouterHandlerCache = {}; + +// things that unauthenticated users can query about themselves +export const publicViewerRouter = router({ + session: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.session) { + UNSTABLE_HANDLER_CACHE.session = await import("./session.handler").then((mod) => mod.sessionHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.session) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.session({ + ctx, + }); + }), + + i18n: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.i18n) { + UNSTABLE_HANDLER_CACHE.i18n = await import("./i18n.handler").then((mod) => mod.i18nHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.i18n) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.i18n({ + ctx, + }); + }), + + countryCode: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.countryCode) { + UNSTABLE_HANDLER_CACHE.countryCode = await import("./countryCode.handler").then( + (mod) => mod.countryCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.countryCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.countryCode({ + ctx, + }); + }), + + samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) { + UNSTABLE_HANDLER_CACHE.samlTenantProduct = await import("./samlTenantProduct.handler").then( + (mod) => mod.samlTenantProductHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.samlTenantProduct({ + ctx, + input, + }); + }), + + stripeCheckoutSession: publicProcedure + .input(ZStripeCheckoutSessionInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) { + UNSTABLE_HANDLER_CACHE.stripeCheckoutSession = await import("./stripeCheckoutSession.handler").then( + (mod) => mod.stripeCheckoutSessionHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.stripeCheckoutSession({ + ctx, + input, + }); + }), + + cityTimezones: publicProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.cityTimezones) { + UNSTABLE_HANDLER_CACHE.cityTimezones = await import("./cityTimezones.handler").then( + (mod) => mod.cityTimezonesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.cityTimezones) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.cityTimezones({ + ctx, + }); + }), + + // REVIEW: This router is part of both the public and private viewer router? + slots: slotsRouter, + event: publicProcedure.input(ZEventInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.event) { + UNSTABLE_HANDLER_CACHE.event = await import("./event.handler").then((mod) => mod.eventHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.event) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.event({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts new file mode 100644 index 0000000000..2228fe65f7 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/cityTimezones.handler.ts @@ -0,0 +1,38 @@ +import type { Session } from "next-auth"; + +type CityTimezonesOptions = { + ctx: { + session: Session | null; + }; +}; + +export const cityTimezonesHandler = async ({ ctx: _ctx }: CityTimezonesOptions) => { + /** + * Lazy loads third party dependency to avoid loading 1.5Mb for ALL tRPC procedures. + * Thanks @roae for the tip 🙏 + **/ + const allCities = await import("city-timezones").then((mod) => mod.cityMapping); + /** + * Filter out all cities that have the same "city" key and only use the one with the highest population. + * This way we return a new array of cities without running the risk of having more than one city + * with the same name on the dropdown and prevent users from mistaking the time zone of the desired city. + */ + const topPopulatedCities: { [key: string]: { city: string; timezone: string; pop: number } } = {}; + allCities.forEach((city) => { + const cityPopulationCount = city.pop; + if ( + topPopulatedCities[city.city]?.pop === undefined || + cityPopulationCount > topPopulatedCities[city.city].pop + ) { + topPopulatedCities[city.city] = { city: city.city, timezone: city.timezone, pop: city.pop }; + } + }); + const uniqueCities = Object.values(topPopulatedCities); + /** Add specific overries in here */ + uniqueCities.forEach((city) => { + if (city.city === "London") city.timezone = "Europe/London"; + if (city.city === "Londonderry") city.city = "London"; + }); + + return uniqueCities; +}; diff --git a/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts b/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/cityTimezones.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/countryCode.handler.ts b/packages/trpc/server/routers/publicViewer/countryCode.handler.ts new file mode 100644 index 0000000000..ca72cdd95a --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/countryCode.handler.ts @@ -0,0 +1,12 @@ +import type { CreateInnerContextOptions } from "../../createContext"; + +type CountryCodeOptions = { + ctx: CreateInnerContextOptions; +}; + +export const countryCodeHandler = async ({ ctx }: CountryCodeOptions) => { + const { req } = ctx; + + const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? ""; + return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode }; +}; diff --git a/packages/trpc/server/routers/publicViewer/countryCode.schema.ts b/packages/trpc/server/routers/publicViewer/countryCode.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/countryCode.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/event.handler.ts b/packages/trpc/server/routers/publicViewer/event.handler.ts new file mode 100644 index 0000000000..8933f38d68 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/event.handler.ts @@ -0,0 +1,15 @@ +import type { PrismaClient } from "@prisma/client"; + +import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; + +import type { TEventInputSchema } from "./event.schema"; + +interface EventHandlerOptions { + ctx: { prisma: PrismaClient }; + input: TEventInputSchema; +} + +export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => { + const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma); + return event; +}; diff --git a/packages/trpc/server/routers/publicViewer/event.schema.ts b/packages/trpc/server/routers/publicViewer/event.schema.ts new file mode 100644 index 0000000000..74e67be447 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/event.schema.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const ZEventInputSchema = z.object({ + username: z.string(), + eventSlug: z.string(), +}); + +export type TEventInputSchema = z.infer; diff --git a/packages/trpc/server/routers/publicViewer/i18n.handler.ts b/packages/trpc/server/routers/publicViewer/i18n.handler.ts new file mode 100644 index 0000000000..82953994b7 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/i18n.handler.ts @@ -0,0 +1,15 @@ +import type { CreateInnerContextOptions } from "../../createContext"; +import { getLocale } from "../../trpc"; + +type I18nOptions = { + ctx: CreateInnerContextOptions; +}; + +export const i18nHandler = async ({ ctx }: I18nOptions) => { + const { locale, i18n } = await getLocale(ctx); + + return { + i18n, + locale, + }; +}; diff --git a/packages/trpc/server/routers/publicViewer/i18n.schema.ts b/packages/trpc/server/routers/publicViewer/i18n.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/i18n.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts new file mode 100644 index 0000000000..272760e7d1 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/samlTenantProduct.handler.ts @@ -0,0 +1,19 @@ +import type { Session } from "next-auth"; + +import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; +import { prisma } from "@calcom/prisma"; + +import type { TSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; + +type SamlTenantProductOptions = { + ctx: { + session: Session | null; + }; + input: TSamlTenantProductInputSchema; +}; + +export const samlTenantProductHandler = async ({ ctx: _ctx, input }: SamlTenantProductOptions) => { + const { email } = input; + + return await samlTenantProduct(prisma, email); +}; diff --git a/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts b/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts new file mode 100644 index 0000000000..f54d1e47cf --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/samlTenantProduct.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZSamlTenantProductInputSchema = z.object({ + email: z.string().email(), +}); + +export type TSamlTenantProductInputSchema = z.infer; diff --git a/packages/trpc/server/routers/publicViewer/session.handler.ts b/packages/trpc/server/routers/publicViewer/session.handler.ts new file mode 100644 index 0000000000..1b4b4454f0 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/session.handler.ts @@ -0,0 +1,11 @@ +import type { Session } from "next-auth"; + +type SessionOptions = { + ctx: { + session: Session | null; + }; +}; + +export const sessionHandler = async ({ ctx }: SessionOptions) => { + return ctx.session; +}; diff --git a/packages/trpc/server/routers/publicViewer/session.schema.ts b/packages/trpc/server/routers/publicViewer/session.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/session.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts new file mode 100644 index 0000000000..7bc54152a6 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts @@ -0,0 +1,72 @@ +import type { Session } from "next-auth"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; + +import type { TStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; + +type StripeCheckoutSessionOptions = { + ctx: { + session: Session | null; + }; + input: TStripeCheckoutSessionInputSchema; +}; + +export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSessionOptions) => { + const { checkoutSessionId, stripeCustomerId } = input; + + // TODO: Move the following data checks to superRefine + if (!checkoutSessionId && !stripeCustomerId) { + throw new Error("Missing checkoutSessionId or stripeCustomerId"); + } + + if (checkoutSessionId && stripeCustomerId) { + throw new Error("Both checkoutSessionId and stripeCustomerId provided"); + } + let customerId: string; + let isPremiumUsername = false; + let hasPaymentFailed = false; + if (checkoutSessionId) { + try { + const session = await stripe.checkout.sessions.retrieve(checkoutSessionId); + if (typeof session.customer !== "string") { + return { + valid: false, + }; + } + customerId = session.customer; + isPremiumUsername = true; + hasPaymentFailed = session.payment_status !== "paid"; + } catch (e) { + return { + valid: false, + }; + } + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + customerId = stripeCustomerId!; + } + + try { + const customer = await stripe.customers.retrieve(customerId); + if (customer.deleted) { + return { + valid: false, + }; + } + + return { + valid: true, + hasPaymentFailed, + isPremiumUsername, + customer: { + username: customer.metadata.username, + email: customer.metadata.email, + stripeCustomerId: customerId, + }, + }; + } catch (e) { + return { + valid: false, + }; + } +}; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts new file mode 100644 index 0000000000..5e307112f2 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZStripeCheckoutSessionInputSchema = z.object({ + stripeCustomerId: z.string().optional(), + checkoutSessionId: z.string().optional(), +}); + +export type TStripeCheckoutSessionInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx deleted file mode 100644 index 8ac94e6b4f..0000000000 --- a/packages/trpc/server/routers/viewer.tsx +++ /dev/null @@ -1,1356 +0,0 @@ -import type { DestinationCalendar, Prisma } from "@prisma/client"; -import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client"; -import { reverse } from "lodash"; -import type { NextApiResponse } from "next"; -import { authenticator } from "otplib"; -import z from "zod"; - -import ethRouter from "@calcom/app-store/rainbow/trpc/router"; -import app_RoutingForms from "@calcom/app-store/routing-forms/trpc-router"; -import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; -import stripe from "@calcom/app-store/stripepayment/lib/server"; -import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; -import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils"; -import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; -import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; -import { DailyLocationType } from "@calcom/core/location"; -import { - getDownloadLinkOfCalVideoByRecordingId, - getRecordingsOfCalVideoByRoomName, -} from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; -import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; -import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; -import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; -import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; -import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; -import { featureFlagRouter } from "@calcom/features/flags/server/router"; -import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; -import { FULL_NAME_LENGTH_MAX_LIMIT, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; -import { symmetricDecrypt } from "@calcom/lib/crypto"; -import getPaymentAppData from "@calcom/lib/getPaymentAppData"; -import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; -import { deletePayment } from "@calcom/lib/payment/deletePayment"; -import { checkUsername } from "@calcom/lib/server/checkUsername"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; -import slugify from "@calcom/lib/slugify"; -import { - deleteWebUser as syncServicesDeleteWebUser, - updateWebUser as syncServicesUpdateWebUser, -} from "@calcom/lib/sync/SyncServiceManager"; -import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { EventTypeMetaDataSchema, userMetadata } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, getLocale, mergeRouters, publicProcedure, router } from "../trpc"; -import { apiKeysRouter } from "./viewer/apiKeys"; -import { appsRouter } from "./viewer/apps"; -import { authRouter } from "./viewer/auth"; -import { availabilityRouter } from "./viewer/availability"; -import { bookingsRouter } from "./viewer/bookings"; -import { deploymentSetupRouter } from "./viewer/deploymentSetup"; -import { eventTypesRouter } from "./viewer/eventTypes"; -import { paymentsRouter } from "./viewer/payments"; -import { slotsRouter } from "./viewer/slots"; -import { ssoRouter } from "./viewer/sso"; -import { viewerTeamsRouter } from "./viewer/teams"; -import { webhookRouter } from "./viewer/webhook"; -import { workflowsRouter } from "./viewer/workflows"; - -// things that unauthenticated users can query about themselves -const publicViewerRouter = router({ - session: publicProcedure.query(({ ctx }) => { - return ctx.session; - }), - i18n: publicProcedure.query(async ({ ctx }) => { - const { locale, i18n } = await getLocale(ctx); - - return { - i18n, - locale, - }; - }), - countryCode: publicProcedure.query(({ ctx }) => { - const { req } = ctx; - - const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? ""; - return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode }; - }), - samlTenantProduct: publicProcedure - .input( - z.object({ - email: z.string().email(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - const { email } = input; - - return await samlTenantProduct(prisma, email); - }), - stripeCheckoutSession: publicProcedure - .input( - z.object({ - stripeCustomerId: z.string().optional(), - checkoutSessionId: z.string().optional(), - }) - ) - .query(async ({ input }) => { - const { checkoutSessionId, stripeCustomerId } = input; - - // TODO: Move the following data checks to superRefine - if (!checkoutSessionId && !stripeCustomerId) { - throw new Error("Missing checkoutSessionId or stripeCustomerId"); - } - - if (checkoutSessionId && stripeCustomerId) { - throw new Error("Both checkoutSessionId and stripeCustomerId provided"); - } - let customerId: string; - let isPremiumUsername = false; - let hasPaymentFailed = false; - if (checkoutSessionId) { - try { - const session = await stripe.checkout.sessions.retrieve(checkoutSessionId); - if (typeof session.customer !== "string") { - return { - valid: false, - }; - } - customerId = session.customer; - isPremiumUsername = true; - hasPaymentFailed = session.payment_status !== "paid"; - } catch (e) { - return { - valid: false, - }; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - customerId = stripeCustomerId!; - } - - try { - const customer = await stripe.customers.retrieve(customerId); - if (customer.deleted) { - return { - valid: false, - }; - } - - return { - valid: true, - hasPaymentFailed, - isPremiumUsername, - customer: { - username: customer.metadata.username, - email: customer.metadata.email, - stripeCustomerId: customerId, - }, - }; - } catch (e) { - return { - valid: false, - }; - } - }), - slots: slotsRouter, - event: publicProcedure - .input( - z.object({ - username: z.string(), - eventSlug: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma); - return event; - }), - cityTimezones: publicProcedure.query(async () => { - /** - * Lazy loads third party dependency to avoid loading 1.5Mb for ALL tRPC procedures. - * Thanks @roae for the tip 🙏 - **/ - const allCities = await import("city-timezones").then((mod) => mod.cityMapping); - /** - * Filter out all cities that have the same "city" key and only use the one with the highest population. - * This way we return a new array of cities without running the risk of having more than one city - * with the same name on the dropdown and prevent users from mistaking the time zone of the desired city. - */ - const topPopulatedCities: { [key: string]: { city: string; timezone: string; pop: number } } = {}; - allCities.forEach((city) => { - const cityPopulationCount = city.pop; - if ( - topPopulatedCities[city.city]?.pop === undefined || - cityPopulationCount > topPopulatedCities[city.city].pop - ) { - topPopulatedCities[city.city] = { city: city.city, timezone: city.timezone, pop: city.pop }; - } - }); - const uniqueCities = Object.values(topPopulatedCities); - /** Add specific overries in here */ - uniqueCities.forEach((city) => { - if (city.city === "London") city.timezone = "Europe/London"; - if (city.city === "Londonderry") city.city = "London"; - }); - return uniqueCities; - }), -}); - -// routes only available to authenticated users -const loggedInViewerRouter = router({ - me: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; - // Destructuring here only makes it more illegible - // pick only the part we want to expose in the API - return { - id: user.id, - name: user.name, - username: user.username, - email: user.email, - startTime: user.startTime, - endTime: user.endTime, - bufferTime: user.bufferTime, - locale: user.locale, - timeFormat: user.timeFormat, - timeZone: user.timeZone, - avatar: user.avatar, - createdDate: user.createdDate, - trialEndsAt: user.trialEndsAt, - defaultScheduleId: user.defaultScheduleId, - completedOnboarding: user.completedOnboarding, - twoFactorEnabled: user.twoFactorEnabled, - disableImpersonation: user.disableImpersonation, - identityProvider: user.identityProvider, - brandColor: user.brandColor, - darkBrandColor: user.darkBrandColor, - away: user.away, - bio: user.bio, - weekStart: user.weekStart, - theme: user.theme, - hideBranding: user.hideBranding, - metadata: user.metadata, - }; - }), - avatar: authedProcedure.query(({ ctx }) => ({ - avatar: ctx.user.rawAvatar, - })), - deleteMe: authedProcedure - .input( - z.object({ - password: z.string(), - totpCode: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - // Check if input.password is correct - const user = await prisma.user.findUnique({ - where: { - email: ctx.user.email.toLowerCase(), - }, - }); - if (!user) { - throw new Error(ErrorCode.UserNotFound); - } - - if (user.identityProvider !== IdentityProvider.CAL) { - throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); - } - - if (!user.password) { - throw new Error(ErrorCode.UserMissingPassword); - } - - const isCorrectPassword = await verifyPassword(input.password, user.password); - if (!isCorrectPassword) { - throw new Error(ErrorCode.IncorrectPassword); - } - - if (user.twoFactorEnabled) { - if (!input.totpCode) { - throw new Error(ErrorCode.SecondFactorRequired); - } - - if (!user.twoFactorSecret) { - console.error(`Two factor is enabled for user ${user.id} but they have no secret`); - throw new Error(ErrorCode.InternalServerError); - } - - if (!process.env.CALENDSO_ENCRYPTION_KEY) { - console.error(`"Missing encryption key; cannot proceed with two factor login."`); - throw new Error(ErrorCode.InternalServerError); - } - - const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY); - if (secret.length !== 32) { - console.error( - `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}` - ); - throw new Error(ErrorCode.InternalServerError); - } - - // If user has 2fa enabled, check if input.totpCode is correct - const isValidToken = authenticator.check(input.totpCode, secret); - if (!isValidToken) { - throw new Error(ErrorCode.IncorrectTwoFactorCode); - } - } - - // If 2FA is disabled or totpCode is valid then delete the user from stripe and database - await deleteStripeCustomer(user).catch(console.warn); - // Remove my account - const deletedUser = await ctx.prisma.user.delete({ - where: { - id: ctx.user.id, - }, - }); - - // Sync Services - syncServicesDeleteWebUser(deletedUser); - return; - }), - deleteMeWithoutPassword: authedProcedure.mutation(async ({ ctx }) => { - const user = await prisma.user.findUnique({ - where: { - email: ctx.user.email.toLowerCase(), - }, - }); - if (!user) { - throw new Error(ErrorCode.UserNotFound); - } - - if (user.identityProvider === IdentityProvider.CAL) { - throw new Error(ErrorCode.SocialIdentityProviderRequired); - } - - if (user.twoFactorEnabled) { - throw new Error(ErrorCode.SocialIdentityProviderRequired); - } - - // Remove me from Stripe - await deleteStripeCustomer(user).catch(console.warn); - - // Remove my account - const deletedUser = await ctx.prisma.user.delete({ - where: { - id: ctx.user.id, - }, - }); - // Sync Services - syncServicesDeleteWebUser(deletedUser); - - return; - }), - away: authedProcedure - .input( - z.object({ - away: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - await ctx.prisma.user.update({ - where: { - email: ctx.user.email, - }, - data: { - away: input.away, - }, - }); - }), - connectedCalendars: authedProcedure.query(async ({ ctx }) => { - const { user, prisma } = ctx; - - const userCredentials = await prisma.credential.findMany({ - where: { - userId: ctx.user.id, - app: { - categories: { has: AppCategories.calendar }, - enabled: true, - }, - }, - }); - - // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(userCredentials); - - // get all the connected integrations' calendars (from third party) - const { connectedCalendars, destinationCalendar } = await getConnectedCalendars( - calendarCredentials, - user.selectedCalendars, - user.destinationCalendar?.externalId - ); - - if (connectedCalendars.length === 0) { - /* As there are no connected calendars, delete the destination calendar if it exists */ - if (user.destinationCalendar) { - await ctx.prisma.destinationCalendar.delete({ - where: { userId: user.id }, - }); - user.destinationCalendar = null; - } - } else if (!user.destinationCalendar) { - /* - There are connected calendars, but no destination calendar - So create a default destination calendar with the first primary connected calendar - */ - const { integration = "", externalId = "", credentialId, email } = connectedCalendars[0].primary ?? {}; - user.destinationCalendar = await ctx.prisma.destinationCalendar.create({ - data: { - userId: user.id, - integration, - externalId, - credentialId, - }, - }); - } else { - /* There are connected calendars and a destination calendar */ - - // Check if destinationCalendar exists in connectedCalendars - const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); - const destinationCal = allCals.find( - (cal) => - cal.externalId === user.destinationCalendar?.externalId && - cal.integration === user.destinationCalendar?.integration - ); - - if (!destinationCal) { - // If destinationCalendar is out of date, update it with the first primary connected calendar - const { integration = "", externalId = "" } = connectedCalendars[0].primary ?? {}; - user.destinationCalendar = await ctx.prisma.destinationCalendar.update({ - where: { userId: user.id }, - data: { - integration, - externalId, - }, - }); - } - } - - return { - connectedCalendars, - destinationCalendar: { - ...(user.destinationCalendar as DestinationCalendar), - ...destinationCalendar, - }, - }; - }), - setDestinationCalendar: authedProcedure - .input( - z.object({ - integration: z.string(), - externalId: z.string(), - eventTypeId: z.number().nullish(), - bookingId: z.number().nullish(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { integration, externalId, eventTypeId } = input; - const calendarCredentials = getCalendarCredentials(user.credentials); - const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); - const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); - - const credentialId = allCals.find( - (cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false - )?.credentialId; - - if (!credentialId) { - throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` }); - } - - let where; - - if (eventTypeId) { - if ( - !(await prisma.eventType.findFirst({ - where: { - id: eventTypeId, - userId: user.id, - }, - })) - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: `You don't have access to event type ${eventTypeId}`, - }); - } - - where = { eventTypeId }; - } else where = { userId: user.id }; - - await ctx.prisma.destinationCalendar.upsert({ - where, - update: { - integration, - externalId, - credentialId, - }, - create: { - ...where, - integration, - externalId, - credentialId, - }, - }); - }), - integrations: authedProcedure - .input( - z.object({ - variant: z.string().optional(), - exclude: z.array(z.string()).optional(), - onlyInstalled: z.boolean().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const { variant, exclude, onlyInstalled } = input; - const { credentials } = user; - - const enabledApps = await getEnabledApps(credentials); - //TODO: Refactor this to pick up only needed fields and prevent more leaking - let apps = enabledApps.map( - ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { - const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); - const invalidCredentialIds = credentials - .filter((c) => c.type === app.type && c.invalid) - .map((c) => c.id); - return { - ...app, - credentialIds, - invalidCredentialIds, - }; - } - ); - - if (variant) { - // `flatMap()` these work like `.filter()` but infers the types correctly - apps = apps - // variant check - .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); - } - - if (exclude) { - // exclusion filter - apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); - } - - if (onlyInstalled) { - apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); - } - return { - items: apps, - }; - }), - appById: authedProcedure - .input( - z.object({ - appId: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const appId = input.appId; - const { credentials } = user; - const apps = getApps(credentials); - const appFromDb = apps.find((app) => app.slug === appId); - if (!appFromDb) { - throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` }); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { credential: _, credentials: _1, ...app } = appFromDb; - return { - isInstalled: appFromDb.credentials.length, - ...app, - }; - }), - apps: authedProcedure - .input( - z.object({ - extendsFeature: z.literal("EventType"), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const { credentials } = user; - - const apps = await getEnabledApps(credentials); - return apps - .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) - .map((app) => ({ - ...app, - isInstalled: !!app.credentials?.length, - })); - }), - appCredentialsByType: authedProcedure - .input( - z.object({ - appType: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); - }), - stripeCustomer: authedProcedure.query(async ({ ctx }) => { - const { - user: { id: userId }, - prisma, - } = ctx; - - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - metadata: true, - }, - }); - - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" }); - } - - const metadata = userMetadata.parse(user.metadata); - - if (!metadata?.stripeCustomerId) { - throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer id" }); - } - // Fetch stripe customer - const stripeCustomerId = metadata?.stripeCustomerId; - const customer = await stripe.customers.retrieve(stripeCustomerId); - if (customer.deleted) { - throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" }); - } - - const username = customer?.metadata?.username || null; - - return { - isPremium: !!metadata?.isPremium, - username, - }; - }), - updateProfile: authedProcedure - .input( - z.object({ - username: z.string().optional(), - name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).optional(), - email: z.string().optional(), - bio: z.string().optional(), - avatar: z.string().optional(), - timeZone: z.string().optional(), - weekStart: z.string().optional(), - hideBranding: z.boolean().optional(), - allowDynamicBooking: z.boolean().optional(), - brandColor: z.string().optional(), - darkBrandColor: z.string().optional(), - theme: z.string().optional().nullable(), - completedOnboarding: z.boolean().optional(), - locale: z.string().optional(), - timeFormat: z.number().optional(), - disableImpersonation: z.boolean().optional(), - metadata: userMetadata.optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const data: Prisma.UserUpdateInput = { - ...input, - metadata: input.metadata as Prisma.InputJsonValue, - }; - let isPremiumUsername = false; - if (input.username) { - const username = slugify(input.username); - // Only validate if we're changing usernames - if (username !== user.username) { - data.username = username; - const response = await checkUsername(username); - isPremiumUsername = response.premium; - if (!response.available) { - throw new TRPCError({ code: "BAD_REQUEST", message: response.message }); - } - } - } - if (input.avatar) { - data.avatar = await resizeBase64Image(input.avatar); - } - const userToUpdate = await prisma.user.findUnique({ - where: { - id: user.id, - }, - }); - - if (!userToUpdate) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); - } - const metadata = userMetadata.parse(userToUpdate.metadata); - - const isPremium = metadata?.isPremium; - if (isPremiumUsername) { - const stripeCustomerId = metadata?.stripeCustomerId; - if (!isPremium || !stripeCustomerId) { - throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" }); - } - - const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId }); - - if (!stripeSubscriptions || !stripeSubscriptions.data.length) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "No stripeSubscription found", - }); - } - - // Iterate over subscriptions and look for premium product id and status active - // @TODO: iterate if stripeSubscriptions.hasMore is true - const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some( - (subscription) => - subscription.items.data[0].price.product === getPremiumPlanProductId() && - subscription.status === "active" - ); - - if (!isPremiumUsernameSubscriptionActive) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You need to pay for premium username", - }); - } - } - - const updatedUser = await prisma.user.update({ - where: { - id: user.id, - }, - data, - select: { - id: true, - username: true, - email: true, - metadata: true, - name: true, - createdDate: true, - }, - }); - - // Sync Services - await syncServicesUpdateWebUser(updatedUser); - - // Notify stripe about the change - if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) { - const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`; - await stripe.customers.update(stripeCustomerId, { - metadata: { - username: updatedUser.username, - email: updatedUser.email, - userId: updatedUser.id, - }, - }); - } - // Revalidate booking pages - const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - team: null, - hidden: false, - }, - select: { - id: true, - slug: true, - }, - }); - // waiting for this isn't needed - Promise.all(eventTypes.map((eventType) => res?.revalidate(`/${ctx.user.username}/${eventType.slug}`))) - .then(() => console.info("Booking pages revalidated")) - .catch((e) => console.error(e)); - } - }), - eventTypeOrder: authedProcedure - .input( - z.object({ - ids: z.array(z.number()), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma, user } = ctx; - const allEventTypes = await ctx.prisma.eventType.findMany({ - select: { - id: true, - }, - where: { - id: { - in: input.ids, - }, - OR: [ - { - userId: user.id, - }, - { - users: { - some: { - id: user.id, - }, - }, - }, - { - team: { - members: { - some: { - userId: user.id, - }, - }, - }, - }, - ], - }, - }); - const allEventTypeIds = new Set(allEventTypes.map((type) => type.id)); - if (input.ids.some((id) => !allEventTypeIds.has(id))) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - await Promise.all( - reverse(input.ids).map((id, position) => { - return prisma.eventType.update({ - where: { - id, - }, - data: { - position, - }, - }); - }) - ); - }), - //Comment for PR: eventTypePosition is not used anywhere - submitFeedback: authedProcedure - .input( - z.object({ - rating: z.string(), - comment: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { rating, comment } = input; - - const feedback = { - username: ctx.user.username || "Nameless", - email: ctx.user.email || "No email address", - rating: rating, - comment: comment, - }; - - await ctx.prisma.feedback.create({ - data: { - date: dayjs().toISOString(), - userId: ctx.user.id, - rating: rating, - comment: comment, - }, - }); - - if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback); - }), - locationOptions: authedProcedure.query(async ({ ctx }) => { - const credentials = await prisma.credential.findMany({ - where: { - userId: ctx.user.id, - }, - select: { - id: true, - type: true, - key: true, - userId: true, - appId: true, - invalid: true, - }, - }); - - const integrations = await getEnabledApps(credentials); - - const t = await getTranslation(ctx.user.locale ?? "en", "common"); - - const locationOptions = getLocationGroupedOptions(integrations, t); - - return locationOptions; - }), - deleteCredential: authedProcedure - .input( - z.object({ - id: z.number(), - externalId: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, externalId } = input; - - const credential = await prisma.credential.findFirst({ - where: { - id: id, - userId: ctx.user.id, - }, - select: { - key: true, - appId: true, - app: { - select: { - slug: true, - categories: true, - dirName: true, - }, - }, - }, - }); - - if (!credential) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - }, - select: { - id: true, - locations: true, - destinationCalendar: { - include: { - credential: true, - }, - }, - price: true, - currency: true, - metadata: true, - }, - }); - - // TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data. - for (const eventType of eventTypes) { - if (eventType.locations) { - // If it's a video, replace the location with Cal video - if (credential.app?.categories.includes(AppCategories.video)) { - // Find the user's event types - - // Look for integration name from app slug - const integrationQuery = - credential.app?.slug === "msteams" ? "office365_video" : credential.app?.slug.split("-")[0]; - - // Check if the event type uses the deleted integration - - // To avoid type errors, need to stringify and parse JSON to use array methods - const locationsSchema = z.array(z.object({ type: z.string() })); - const locations = locationsSchema.parse(eventType.locations); - - const updatedLocations = locations.map((location: { type: string }) => { - if (location.type.includes(integrationQuery)) { - return { type: DailyLocationType }; - } - return location; - }); - - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - locations: updatedLocations, - }, - }); - } - } - - // If it's a calendar, remove the destination calendar from the event type - if (credential.app?.categories.includes(AppCategories.calendar)) { - if (eventType.destinationCalendar?.credential?.appId === credential.appId) { - const destinationCalendar = await prisma.destinationCalendar.findFirst({ - where: { - id: eventType.destinationCalendar?.id, - }, - }); - if (destinationCalendar) { - await prisma.destinationCalendar.delete({ - where: { - id: destinationCalendar.id, - }, - }); - } - } - - if (externalId) { - const existingSelectedCalendar = await prisma.selectedCalendar.findFirst({ - where: { - externalId: externalId, - }, - }); - // @TODO: SelectedCalendar doesn't have unique ID so we should only delete one item - if (existingSelectedCalendar) { - await prisma.selectedCalendar.delete({ - where: { - userId_integration_externalId: { - userId: existingSelectedCalendar.userId, - externalId: existingSelectedCalendar.externalId, - integration: existingSelectedCalendar.integration, - }, - }, - }); - } - } - } - - const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); - - const stripeAppData = getPaymentAppData({ ...eventType, metadata }); - - // If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings - if (credential.app?.categories.includes(AppCategories.payment)) { - if (stripeAppData.price) { - await prisma.$transaction(async () => { - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - hidden: true, - metadata: { - ...metadata, - apps: { - ...metadata?.apps, - stripe: { - ...metadata?.apps?.stripe, - price: 0, - }, - }, - }, - }, - }); - - // Assuming that all bookings under this eventType need to be paid - const unpaidBookings = await prisma.booking.findMany({ - where: { - userId: ctx.user.id, - eventTypeId: eventType.id, - status: "PENDING", - paid: false, - payment: { - every: { - success: false, - }, - }, - }, - select: { - ...bookingMinimalSelect, - recurringEventId: true, - userId: true, - responses: true, - user: { - select: { - id: true, - email: true, - timeZone: true, - name: true, - destinationCalendar: true, - locale: true, - }, - }, - location: true, - references: { - select: { - uid: true, - type: true, - externalCalendarId: true, - }, - }, - payment: true, - paid: true, - eventType: { - select: { - recurringEvent: true, - title: true, - bookingFields: true, - seatsPerTimeSlot: true, - seatsShowAttendees: true, - }, - }, - uid: true, - eventTypeId: true, - destinationCalendar: true, - }, - }); - - for (const booking of unpaidBookings) { - await prisma.booking.update({ - where: { - id: booking.id, - }, - data: { - status: BookingStatus.CANCELLED, - cancellationReason: "Payment method removed", - }, - }); - - for (const payment of booking.payment) { - try { - await deletePayment(payment.id, credential); - } catch (e) { - console.error(e); - } - await prisma.payment.delete({ - where: { - id: payment.id, - }, - }); - } - - await prisma.attendee.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - - await prisma.bookingReference.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - - const attendeesListPromises = booking.attendees.map(async (attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common"); - await sendCancelledEmails({ - type: booking?.eventType?.title as string, - title: booking.title, - description: booking.description, - customInputs: isPrismaObjOrUndefined(booking.customInputs), - ...getCalEventResponses({ - bookingFields: booking.eventType?.bookingFields ?? null, - booking, - }), - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - email: booking?.user?.email as string, - name: booking?.user?.name ?? "Nameless", - timeZone: booking?.user?.timeZone as string, - language: { translate: tOrganizer, locale: booking?.user?.locale ?? "en" }, - }, - attendees: attendeesList, - uid: booking.uid, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - location: booking.location, - destinationCalendar: booking.destinationCalendar || booking.user?.destinationCalendar, - cancellationReason: "Payment method removed by organizer", - seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, - seatsShowAttendees: booking.eventType?.seatsShowAttendees, - }); - } - }); - } - } - } - - // if zapier get disconnected, delete zapier apiKey, delete zapier webhooks and cancel all scheduled jobs from zapier - if (credential.app?.slug === "zapier") { - await prisma.apiKey.deleteMany({ - where: { - userId: ctx.user.id, - appId: "zapier", - }, - }); - await prisma.webhook.deleteMany({ - where: { - userId: ctx.user.id, - appId: "zapier", - }, - }); - const bookingsWithScheduledJobs = await prisma.booking.findMany({ - where: { - userId: ctx.user.id, - scheduledJobs: { - isEmpty: false, - }, - }, - }); - for (const booking of bookingsWithScheduledJobs) { - cancelScheduledJobs(booking, credential.appId); - } - } - - // Validated that credential is user's above - await prisma.credential.delete({ - where: { - id: id, - }, - }); - // Revalidate user calendar cache. - if (credential.app?.slug.includes("calendar")) { - await fetch(`${WEBAPP_URL}/api/revalidate-calendar-cache/${ctx?.user?.username}`); - } - }), - bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - const count = await prisma.booking.count({ - where: { - status: BookingStatus.PENDING, - userId: user.id, - endTime: { gt: new Date() }, - }, - }); - const recurringGrouping = await prisma.booking.groupBy({ - by: ["recurringEventId"], - _count: { - recurringEventId: true, - }, - where: { - recurringEventId: { not: { equals: null } }, - status: { equals: "PENDING" }, - userId: user.id, - endTime: { gt: new Date() }, - }, - }); - return recurringGrouping.reduce((prev, current) => { - // recurringEventId is the total number of recurring instances for a booking - // we need to subtract all but one, to represent a single recurring booking - return prev - (current._count?.recurringEventId - 1); - }, count); - }), - getCalVideoRecordings: authedProcedure - .input( - z.object({ - roomName: z.string(), - }) - ) - .query(async ({ input }) => { - const { roomName } = input; - - try { - const res = await getRecordingsOfCalVideoByRoomName(roomName); - return res; - } catch (err) { - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - }), - getDownloadLinkOfCalVideoRecordings: authedProcedure - .input( - z.object({ - recordingId: z.string(), - }) - ) - .query(async ({ input, ctx }) => { - const { recordingId } = input; - const { session } = ctx; - - const isDownloadAllowed = IS_SELF_HOSTED || session.user.belongsToActiveTeam; - - if (!isDownloadAllowed) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - - try { - const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId); - return res; - } catch (err) { - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - }), - getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => { - return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp; - }), - updateUserDefaultConferencingApp: authedProcedure - .input( - z.object({ - appSlug: z.string().optional(), - appLink: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const currentMetadata = userMetadata.parse(ctx.user.metadata); - const credentials = ctx.user.credentials; - const foundApp = getApps(credentials).filter((app) => app.slug === input.appSlug)[0]; - const appLocation = foundApp?.appData?.location; - - if (!foundApp || !appLocation) - throw new TRPCError({ code: "BAD_REQUEST", message: "App not installed" }); - - if (appLocation.linkType === "static" && !input.appLink) { - throw new TRPCError({ code: "BAD_REQUEST", message: "App link is required" }); - } - - if (appLocation.linkType === "static" && appLocation.urlRegExp) { - const validLink = z - .string() - .regex(new RegExp(appLocation.urlRegExp), "Invalid App Link") - .parse(input.appLink); - if (!validLink) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid app link" }); - } - } - - await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - metadata: { - ...currentMetadata, - defaultConferencingApp: { - appSlug: input.appSlug, - appLink: input.appLink, - }, - }, - }, - }); - return input; - }), -}); - -export const viewerRouter = mergeRouters( - loggedInViewerRouter, - router({ - loggedInViewerRouter, - public: publicViewerRouter, - auth: authRouter, - deploymentSetup: deploymentSetupRouter, - bookings: bookingsRouter, - eventTypes: eventTypesRouter, - availability: availabilityRouter, - teams: viewerTeamsRouter, - webhook: webhookRouter, - apiKeys: apiKeysRouter, - workflows: workflowsRouter, - saml: ssoRouter, - insights: insightsRouter, - // NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved. - // After that there would just one merge call here for all the apps. - appRoutingForms: app_RoutingForms, - eth: ethRouter, - features: featureFlagRouter, - payments: paymentsRouter, - appsRouter, - users: userAdminRouter, - }) -); diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx new file mode 100644 index 0000000000..762ffb5bc7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -0,0 +1,50 @@ +import ethRouter from "@calcom/app-store/rainbow/trpc/router"; +import app_RoutingForms from "@calcom/app-store/routing-forms/trpc-router"; +import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; +import { featureFlagRouter } from "@calcom/features/flags/server/router"; +import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; + +import { mergeRouters, router } from "../../trpc"; +import { loggedInViewerRouter } from "../loggedInViewer/_router"; +import { publicViewerRouter } from "../publicViewer/_router"; +import { apiKeysRouter } from "./apiKeys/_router"; +import { appsRouter } from "./apps/_router"; +import { authRouter } from "./auth/_router"; +import { availabilityRouter } from "./availability/_router"; +import { bookingsRouter } from "./bookings/_router"; +import { deploymentSetupRouter } from "./deploymentSetup/_router"; +import { eventTypesRouter } from "./eventTypes/_router"; +import { paymentsRouter } from "./payments/_router"; +import { slotsRouter } from "./slots/_router"; +import { ssoRouter } from "./sso/_router"; +import { viewerTeamsRouter } from "./teams/_router"; +import { webhookRouter } from "./webhook/_router"; +import { workflowsRouter } from "./workflows/_router"; + +export const viewerRouter = mergeRouters( + loggedInViewerRouter, + router({ + loggedInViewerRouter, + public: publicViewerRouter, + auth: authRouter, + deploymentSetup: deploymentSetupRouter, + bookings: bookingsRouter, + eventTypes: eventTypesRouter, + availability: availabilityRouter, + teams: viewerTeamsRouter, + webhook: webhookRouter, + apiKeys: apiKeysRouter, + slots: slotsRouter, + workflows: workflowsRouter, + saml: ssoRouter, + insights: insightsRouter, + payments: paymentsRouter, + // NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved. + // After that there would just one merge call here for all the apps. + appRoutingForms: app_RoutingForms, + eth: ethRouter, + features: featureFlagRouter, + appsRouter, + users: userAdminRouter, + }) +); diff --git a/packages/trpc/server/routers/viewer/apiKeys.tsx b/packages/trpc/server/routers/viewer/apiKeys.tsx deleted file mode 100644 index 72f5547e5e..0000000000 --- a/packages/trpc/server/routers/viewer/apiKeys.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { v4 } from "uuid"; -import { z } from "zod"; - -import { generateUniqueAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; - -import { router, authedProcedure } from "../../trpc"; - -export const apiKeysRouter = router({ - list: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.apiKey.findMany({ - where: { - userId: ctx.user.id, - OR: [ - { - NOT: { - appId: "zapier", - }, - }, - { - appId: null, - }, - ], - }, - orderBy: { createdAt: "desc" }, - }); - }), - findKeyOfType: authedProcedure - .input( - z.object({ - appId: z.string().optional().nullable(), - }) - ) - .query(async ({ ctx, input }) => { - return await ctx.prisma.apiKey.findFirst({ - where: { - AND: [ - { - userId: ctx.user.id, - }, - { - appId: input.appId, - }, - ], - }, - }); - }), - create: authedProcedure - .input( - z.object({ - note: z.string().optional().nullish(), - expiresAt: z.date().optional().nullable(), - neverExpires: z.boolean().optional(), - appId: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const [hashedApiKey, apiKey] = generateUniqueAPIKey(); - // Here we snap never expires before deleting it so it's not passed to prisma create call. - const neverExpires = input.neverExpires; - delete input.neverExpires; - await ctx.prisma.apiKey.create({ - data: { - id: v4(), - userId: ctx.user.id, - ...input, - // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input - expiresAt: neverExpires ? null : input.expiresAt, - hashedKey: hashedApiKey, - }, - }); - const prefixedApiKey = `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`; - return prefixedApiKey; - }), - edit: authedProcedure - .input( - z.object({ - id: z.string(), - note: z.string().optional().nullish(), - expiresAt: z.date().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ...data } = input; - const { - apiKeys: [updatedApiKey], - } = await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - apiKeys: { - update: { - where: { - id, - }, - data, - }, - }, - }, - select: { - apiKeys: { - where: { - id, - }, - }, - }, - }); - return updatedApiKey; - }), - delete: authedProcedure - .input( - z.object({ - id: z.string(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const apiKeyToDelete = await ctx.prisma.apiKey.findFirst({ - where: { - id, - }, - }); - - await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - apiKeys: { - delete: { - id, - }, - }, - }, - }); - - //remove all existing zapier webhooks, as we always have only one zapier API key and the running zaps won't work any more if this key is deleted - if (apiKeyToDelete && apiKeyToDelete.appId === "zapier") { - await ctx.prisma.webhook.deleteMany({ - where: { - userId: ctx.user.id, - appId: "zapier", - }, - }); - } - - return { - id, - }; - }), -}); diff --git a/packages/trpc/server/routers/viewer/apiKeys/_router.tsx b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx new file mode 100644 index 0000000000..35d5f4b285 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/_router.tsx @@ -0,0 +1,101 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZEditInputSchema } from "./edit.schema"; +import { ZFindKeyOfTypeInputSchema } from "./findKeyOfType.schema"; + +type ApiKeysRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + findKeyOfType?: typeof import("./findKeyOfType.handler").findKeyOfTypeHandler; + create?: typeof import("./create.handler").createHandler; + edit?: typeof import("./edit.handler").editHandler; + delete?: typeof import("./delete.handler").deleteHandler; +}; + +const UNSTABLE_HANDLER_CACHE: ApiKeysRouterHandlerCache = {}; + +export const apiKeysRouter = router({ + // List keys + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + // Find key of type + findKeyOfType: authedProcedure.input(ZFindKeyOfTypeInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.findKeyOfType) { + UNSTABLE_HANDLER_CACHE.findKeyOfType = await import("./findKeyOfType.handler").then( + (mod) => mod.findKeyOfTypeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.findKeyOfType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.findKeyOfType({ + ctx, + input, + }); + }), + + // Create a new key + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + edit: authedProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.edit) { + UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.edit) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.edit({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts new file mode 100644 index 0000000000..ae5b33b61b --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts @@ -0,0 +1,38 @@ +import { v4 } from "uuid"; + +import { generateUniqueAPIKey } from "@calcom/ee/api-keys/lib/apiKeys"; +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateHandlerOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => { + const [hashedApiKey, apiKey] = generateUniqueAPIKey(); + + // Here we snap never expires before deleting it so it's not passed to prisma create call. + const { neverExpires, ...rest } = input; + + await prisma.apiKey.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...rest, + // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input + expiresAt: neverExpires ? null : rest.expiresAt, + hashedKey: hashedApiKey, + }, + }); + + const apiKeyPrefix = process.env.API_KEY_PREFIX ?? "cal_"; + + const prefixedApiKey = `${apiKeyPrefix}${apiKey}`; + + return prefixedApiKey; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts new file mode 100644 index 0000000000..b38efc1957 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + note: z.string().optional().nullish(), + expiresAt: z.date().optional().nullable(), + neverExpires: z.boolean().optional(), + appId: z.string().optional().nullable(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts new file mode 100644 index 0000000000..211275a304 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/delete.handler.ts @@ -0,0 +1,48 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + + const apiKeyToDelete = await prisma.apiKey.findFirst({ + where: { + id, + }, + }); + + await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + delete: { + id, + }, + }, + }, + }); + + //remove all existing zapier webhooks, as we always have only one zapier API key and the running zaps won't work any more if this key is deleted + if (apiKeyToDelete && apiKeyToDelete.appId === "zapier") { + await prisma.webhook.deleteMany({ + where: { + userId: ctx.user.id, + appId: "zapier", + }, + }); + } + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts new file mode 100644 index 0000000000..6c5df814ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/delete.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.string(), + eventTypeId: z.number().optional(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts new file mode 100644 index 0000000000..a8d1c1be20 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/edit.handler.ts @@ -0,0 +1,42 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TEditInputSchema } from "./edit.schema"; + +type EditOptions = { + ctx: { + user: NonNullable; + }; + input: TEditInputSchema; +}; + +export const editHandler = async ({ ctx, input }: EditOptions) => { + const { id, ...data } = input; + + const { + apiKeys: [updatedApiKey], + } = await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + apiKeys: { + update: { + where: { + id, + }, + data, + }, + }, + }, + select: { + apiKeys: { + where: { + id, + }, + }, + }, + }); + + return updatedApiKey; +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts new file mode 100644 index 0000000000..ae4edac3e8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/edit.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZEditInputSchema = z.object({ + id: z.string(), + note: z.string().optional().nullish(), + expiresAt: z.date().optional(), +}); + +export type TEditInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts new file mode 100644 index 0000000000..2bb04f3b70 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts @@ -0,0 +1,26 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TFindKeyOfTypeInputSchema } from "./findKeyOfType.schema"; + +type FindKeyOfTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TFindKeyOfTypeInputSchema; +}; + +export const findKeyOfTypeHandler = async ({ ctx, input }: FindKeyOfTypeOptions) => { + return await prisma.apiKey.findFirst({ + where: { + AND: [ + { + userId: ctx.user.id, + }, + { + appId: input.appId, + }, + ], + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts new file mode 100644 index 0000000000..1f83f53d38 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZFindKeyOfTypeInputSchema = z.object({ + appId: z.string().optional(), +}); + +export type TFindKeyOfTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts new file mode 100644 index 0000000000..1d38c5695f --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/list.handler.ts @@ -0,0 +1,28 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + return await prisma.apiKey.findMany({ + where: { + userId: ctx.user.id, + OR: [ + { + NOT: { + appId: "zapier", + }, + }, + { + appId: null, + }, + ], + }, + orderBy: { createdAt: "desc" }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/apps.tsx b/packages/trpc/server/routers/viewer/apps.tsx deleted file mode 100644 index ffc6d1895c..0000000000 --- a/packages/trpc/server/routers/viewer/apps.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { AppCategories } from "@prisma/client"; -import type { Prisma } from "@prisma/client"; -import z from "zod"; - -import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; -import { getLocalAppMetadata, getAppFromSlug } from "@calcom/app-store/utils"; -import { sendDisabledAppEmail } from "@calcom/emails"; -import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; -import { getTranslation } from "@calcom/lib/server/i18n"; - -import { TRPCError } from "@trpc/server"; - -import { authedAdminProcedure, authedProcedure, router } from "../../trpc"; - -export const appsRouter = router({ - listLocal: authedAdminProcedure - .input( - z.object({ - category: z.nativeEnum({ ...AppCategories, conferencing: "conferencing" }), - }) - ) - .query(async ({ ctx, input }) => { - const category = input.category === "conferencing" ? "video" : input.category; - const localApps = getLocalAppMetadata(); - - const dbApps = await ctx.prisma.app.findMany({ - where: { - categories: { - has: AppCategories[category as keyof typeof AppCategories], - }, - }, - select: { - slug: true, - keys: true, - enabled: true, - dirName: true, - }, - }); - - return localApps.flatMap((app) => { - // Filter applications that does not belong to the current requested category. - if (!(app.category === category || app.categories?.some((appCategory) => appCategory === category))) { - return []; - } - - // Find app metadata - const dbData = dbApps.find((dbApp) => dbApp.slug === app.slug); - - // If the app already contains keys then return - if (dbData?.keys) { - return { - name: app.name, - slug: app.slug, - logo: app.logo, - title: app.title, - type: app.type, - description: app.description, - // We know that keys are going to be an object or null. Prisma can not type check against JSON fields - keys: dbData.keys as Prisma.JsonObject | null, - dirName: app.dirName || app.slug, - enabled: dbData?.enabled || false, - isTemplate: app.isTemplate, - }; - } - - const keysSchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas]; - - const keys: Record = {}; - - // `typeof val === 'undefined'` is always slower than !== undefined comparison - // it is important to avoid string to string comparisons as much as we can - if (keysSchema !== undefined) { - // TODO: Remove the Object.values and reduce to improve the performance. - Object.values(keysSchema.keyof()._def.values).reduce((keysObject, key) => { - keys[key as string] = ""; - return keysObject; - }, {} as Record); - } - - return { - name: app.name, - slug: app.slug, - logo: app.logo, - type: app.type, - title: app.title, - description: app.description, - enabled: dbData?.enabled ?? false, - dirName: app.dirName ?? app.slug, - keys: Object.keys(keys).length === 0 ? null : keys, - }; - }); - }), - toggle: authedAdminProcedure - .input( - z.object({ - slug: z.string(), - enabled: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - const { enabled } = input; - - // Get app name from metadata - const localApps = getLocalAppMetadata(); - const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); - - if (!appMetadata) - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); - - const app = await prisma.app.upsert({ - where: { - slug: input.slug, - }, - update: { - enabled, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - }, - create: { - slug: input.slug, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - categories: - (appMetadata?.categories as AppCategories[]) || - ([appMetadata?.category] as AppCategories[]) || - undefined, - keys: undefined, - enabled, - }, - }); - - // If disabling an app then we need to alert users based on the app type - if (!enabled) { - const translations = new Map(); - - if (app.categories.some((category) => ["calendar", "video"].includes(category))) { - // Find all users with the app credentials - const appCredentials = await prisma.credential.findMany({ - where: { - appId: app.slug, - }, - select: { - user: { - select: { - email: true, - locale: true, - }, - }, - }, - }); - - // TODO: This should be done async probably using a queue. - Promise.all( - appCredentials.map(async (credential) => { - // No need to continue if credential does not have a user - if (!credential.user || !credential.user.email) return; - - const locale = credential.user.locale ?? "en"; - let t = translations.get(locale); - - if (!t) { - t = await getTranslation(locale, "common"); - translations.set(locale, t); - } - - await sendDisabledAppEmail({ - email: credential.user.email, - appName: appMetadata?.name || app.slug, - appType: app.categories, - t, - }); - }) - ); - } else { - const eventTypesWithApp = await prisma.eventType.findMany({ - where: { - metadata: { - path: ["apps", app.slug as string, "enabled"], - equals: true, - }, - }, - select: { - id: true, - title: true, - users: { - select: { - email: true, - locale: true, - }, - }, - metadata: true, - }, - }); - - // TODO: This should be done async probably using a queue. - Promise.all( - eventTypesWithApp.map(async (eventType) => { - // TODO: This update query can be removed by merging it with - // the previous `findMany` query, if that query returns certain values. - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - metadata: { - ...(eventType.metadata as object), - apps: { - // From this comment we can not type JSON fields in Prisma https://github.com/prisma/prisma/issues/3219#issuecomment-670202980 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - ...eventType.metadata?.apps, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - [app.slug]: { ...eventType.metadata?.apps[app.slug], enabled: false }, - }, - }, - }, - }); - - return Promise.all( - eventType.users.map(async (user) => { - const locale = user.locale ?? "en"; - let t = translations.get(locale); - - if (!t) { - t = await getTranslation(locale, "common"); - translations.set(locale, t); - } - - await sendDisabledAppEmail({ - email: user.email, - appName: appMetadata?.name || app.slug, - appType: app.categories, - t, - title: eventType.title, - eventTypeId: eventType.id, - }); - }) - ); - }) - ); - } - } - - return app.enabled; - }), - saveKeys: authedAdminProcedure - .input( - z.object({ - slug: z.string(), - dirName: z.string(), - type: z.string(), - // Validate w/ app specific schema - keys: z.unknown(), - fromEnabled: z.boolean().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const keysSchema = appKeysSchemas[input.dirName as keyof typeof appKeysSchemas]; - const keys = keysSchema.parse(input.keys); - - // Get app name from metadata - const localApps = getLocalAppMetadata(); - const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); - - if (!appMetadata?.dirName && appMetadata?.categories) - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); - - await ctx.prisma.app.upsert({ - where: { - slug: input.slug, - }, - update: { keys, ...(input.fromEnabled && { enabled: true }) }, - create: { - slug: input.slug, - dirName: appMetadata?.dirName || appMetadata?.slug || "", - categories: - (appMetadata?.categories as AppCategories[]) || - ([appMetadata?.category] as AppCategories[]) || - undefined, - keys: (input.keys as Prisma.InputJsonObject) || undefined, - ...(input.fromEnabled && { enabled: true }), - }, - }); - }), - checkForGCal: authedProcedure.query(async ({ ctx }) => { - const gCalPresent = await ctx.prisma.credential.findFirst({ - where: { - type: "google_calendar", - userId: ctx.user.id, - }, - }); - return !!gCalPresent; - }), - updateAppCredentials: authedProcedure - .input( - z.object({ - credentialId: z.number(), - key: z.object({}).passthrough(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - - const { key } = input; - - // Find user credential - const credential = await ctx.prisma.credential.findFirst({ - where: { - id: input.credentialId, - userId: user.id, - }, - }); - // Check if credential exists - if (!credential) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Could not find credential ${input.credentialId}`, - }); - } - - const updated = await ctx.prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: { - ...(credential.key as Prisma.JsonObject), - ...(key as Prisma.JsonObject), - }, - }, - }); - - return !!updated; - }), - queryForDependencies: authedProcedure.input(z.string().array().optional()).query(async ({ ctx, input }) => { - if (!input) return; - - const dependencyData: { name: string; slug: string; installed: boolean }[] = []; - - await Promise.all( - input.map(async (dependency) => { - const appInstalled = await ctx.prisma.credential.findFirst({ - where: { - appId: dependency, - userId: ctx.user.id, - }, - }); - - const app = await getAppFromSlug(dependency); - - dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); - }) - ); - - return dependencyData; - }), -}); diff --git a/packages/trpc/server/routers/viewer/apps/_router.tsx b/packages/trpc/server/routers/viewer/apps/_router.tsx new file mode 100644 index 0000000000..1589dae1d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/_router.tsx @@ -0,0 +1,126 @@ +import { authedAdminProcedure, authedProcedure, router } from "../../../trpc"; +import { ZListLocalInputSchema } from "./listLocal.schema"; +import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; +import { ZSaveKeysInputSchema } from "./saveKeys.schema"; +import { ZToggleInputSchema } from "./toggle.schema"; +import { ZUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema"; + +type AppsRouterHandlerCache = { + listLocal?: typeof import("./listLocal.handler").listLocalHandler; + toggle?: typeof import("./toggle.handler").toggleHandler; + saveKeys?: typeof import("./saveKeys.handler").saveKeysHandler; + checkForGCal?: typeof import("./checkForGCal.handler").checkForGCalHandler; + updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler; + queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; + +export const appsRouter = router({ + listLocal: authedAdminProcedure.input(ZListLocalInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listLocal) { + UNSTABLE_HANDLER_CACHE.listLocal = await import("./listLocal.handler").then( + (mod) => mod.listLocalHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listLocal) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listLocal({ + ctx, + input, + }); + }), + + toggle: authedAdminProcedure.input(ZToggleInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.toggle) { + UNSTABLE_HANDLER_CACHE.toggle = await import("./toggle.handler").then((mod) => mod.toggleHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.toggle) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.toggle({ + ctx, + input, + }); + }), + + saveKeys: authedAdminProcedure.input(ZSaveKeysInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.saveKeys) { + UNSTABLE_HANDLER_CACHE.saveKeys = await import("./saveKeys.handler").then((mod) => mod.saveKeysHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.saveKeys) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.saveKeys({ + ctx, + input, + }); + }), + + checkForGCal: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.checkForGCal) { + UNSTABLE_HANDLER_CACHE.checkForGCal = await import("./checkForGCal.handler").then( + (mod) => mod.checkForGCalHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.checkForGCal) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.checkForGCal({ + ctx, + }); + }), + + updateAppCredentials: authedProcedure + .input(ZUpdateAppCredentialsInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateAppCredentials) { + UNSTABLE_HANDLER_CACHE.updateAppCredentials = await import("./updateAppCredentials.handler").then( + (mod) => mod.updateAppCredentialsHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateAppCredentials) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateAppCredentials({ + ctx, + input, + }); + }), + + queryForDependencies: authedProcedure + .input(ZQueryForDependenciesInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.queryForDependencies) { + UNSTABLE_HANDLER_CACHE.queryForDependencies = await import("./queryForDependencies.handler").then( + (mod) => mod.queryForDependenciesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.queryForDependencies) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.queryForDependencies({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts b/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts new file mode 100644 index 0000000000..fbff33af54 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkForGCal.handler.ts @@ -0,0 +1,20 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type CheckForGCalOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const checkForGCalHandler = async ({ ctx }: CheckForGCalOptions) => { + const gCalPresent = await prisma.credential.findFirst({ + where: { + type: "google_calendar", + userId: ctx.user.id, + }, + }); + + return !!gCalPresent; +}; diff --git a/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts b/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkForGCal.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts b/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts new file mode 100644 index 0000000000..3771a93206 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/listLocal.handler.ts @@ -0,0 +1,89 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; +import { AppCategories } from "@prisma/client"; + +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import { getLocalAppMetadata } from "@calcom/app-store/utils"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TListLocalInputSchema } from "./listLocal.schema"; + +type ListLocalOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TListLocalInputSchema; +}; + +export const listLocalHandler = async ({ ctx, input }: ListLocalOptions) => { + const { prisma } = ctx; + const category = input.category === "conferencing" ? "video" : input.category; + const localApps = getLocalAppMetadata(); + + const dbApps = await prisma.app.findMany({ + where: { + categories: { + has: AppCategories[category as keyof typeof AppCategories], + }, + }, + select: { + slug: true, + keys: true, + enabled: true, + dirName: true, + }, + }); + + return localApps.flatMap((app) => { + // Filter applications that does not belong to the current requested category. + if (!(app.category === category || app.categories?.some((appCategory) => appCategory === category))) { + return []; + } + + // Find app metadata + const dbData = dbApps.find((dbApp) => dbApp.slug === app.slug); + + // If the app already contains keys then return + if (dbData?.keys) { + return { + name: app.name, + slug: app.slug, + logo: app.logo, + title: app.title, + type: app.type, + description: app.description, + // We know that keys are going to be an object or null. Prisma can not type check against JSON fields + keys: dbData.keys as Prisma.JsonObject | null, + dirName: app.dirName || app.slug, + enabled: dbData?.enabled || false, + isTemplate: app.isTemplate, + }; + } + + const keysSchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas]; + + const keys: Record = {}; + + // `typeof val === 'undefined'` is always slower than !== undefined comparison + // it is important to avoid string to string comparisons as much as we can + if (keysSchema !== undefined) { + // TODO: Remove the Object.values and reduce to improve the performance. + Object.values(keysSchema.keyof()._def.values).reduce((keysObject, key) => { + keys[key as string] = ""; + return keysObject; + }, {} as Record); + } + + return { + name: app.name, + slug: app.slug, + logo: app.logo, + type: app.type, + title: app.title, + description: app.description, + enabled: dbData?.enabled ?? false, + dirName: app.dirName ?? app.slug, + keys: Object.keys(keys).length === 0 ? null : keys, + }; + }); +}; diff --git a/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts b/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts new file mode 100644 index 0000000000..03861aa8a1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/listLocal.schema.ts @@ -0,0 +1,8 @@ +import { AppCategories } from "@prisma/client"; +import { z } from "zod"; + +export const ZListLocalInputSchema = z.object({ + category: z.nativeEnum({ ...AppCategories, conferencing: "conferencing" }), +}); + +export type TListLocalInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts new file mode 100644 index 0000000000..5e78c83c64 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts @@ -0,0 +1,35 @@ +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; + +type QueryForDependenciesOptions = { + ctx: { + user: NonNullable; + }; + input: TQueryForDependenciesInputSchema; +}; + +export const queryForDependenciesHandler = async ({ ctx, input }: QueryForDependenciesOptions) => { + if (!input) return; + + const dependencyData: { name: string; slug: string; installed: boolean }[] = []; + + await Promise.all( + input.map(async (dependency) => { + const appInstalled = await prisma.credential.findFirst({ + where: { + appId: dependency, + userId: ctx.user.id, + }, + }); + + const app = await getAppFromSlug(dependency); + + dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); + }) + ); + + return dependencyData; +}; diff --git a/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts b/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts new file mode 100644 index 0000000000..8adbf5bfd2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/queryForDependencies.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ZQueryForDependenciesInputSchema = z.string().array().optional(); + +export type TQueryForDependenciesInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts b/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts new file mode 100644 index 0000000000..f293609252 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/saveKeys.handler.ts @@ -0,0 +1,49 @@ +import type { AppCategories } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; + +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import { getLocalAppMetadata } from "@calcom/app-store/utils"; + +// import prisma from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TSaveKeysInputSchema } from "./saveKeys.schema"; + +type SaveKeysOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TSaveKeysInputSchema; +}; + +export const saveKeysHandler = async ({ ctx, input }: SaveKeysOptions) => { + const keysSchema = appKeysSchemas[input.dirName as keyof typeof appKeysSchemas]; + const keys = keysSchema.parse(input.keys); + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); + + if (!appMetadata?.dirName && appMetadata?.categories) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + + await ctx.prisma.app.upsert({ + where: { + slug: input.slug, + }, + update: { keys, ...(input.fromEnabled && { enabled: true }) }, + create: { + slug: input.slug, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: (input.keys as Prisma.InputJsonObject) || undefined, + ...(input.fromEnabled && { enabled: true }), + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts b/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts new file mode 100644 index 0000000000..0e1bd59c01 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/saveKeys.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ZSaveKeysInputSchema = z.object({ + slug: z.string(), + dirName: z.string(), + type: z.string(), + // Validate w/ app specific schema + keys: z.unknown(), + fromEnabled: z.boolean().optional(), +}); + +export type TSaveKeysInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/toggle.handler.ts b/packages/trpc/server/routers/viewer/apps/toggle.handler.ts new file mode 100644 index 0000000000..2c1502955c --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/toggle.handler.ts @@ -0,0 +1,166 @@ +import type { PrismaClient } from "@prisma/client"; + +import { getLocalAppMetadata } from "@calcom/app-store/utils"; +import { sendDisabledAppEmail } from "@calcom/emails"; +import { getTranslation } from "@calcom/lib/server"; +import type { AppCategories } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TToggleInputSchema } from "./toggle.schema"; + +type ToggleOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TToggleInputSchema; +}; + +export const toggleHandler = async ({ input, ctx }: ToggleOptions) => { + const { prisma } = ctx; + const { enabled, slug } = input; + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === slug); + + if (!appMetadata) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + } + + const app = await prisma.app.upsert({ + where: { + slug, + }, + update: { + enabled, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + }, + create: { + slug, + dirName: appMetadata?.dirName || appMetadata?.slug || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: undefined, + enabled, + }, + }); + + // If disabling an app then we need to alert users basesd on the app type + if (!enabled) { + const translations = new Map(); + if (app.categories.some((category) => ["calendar", "video"].includes(category))) { + // Find all users with the app credentials + const appCredentials = await prisma.credential.findMany({ + where: { + appId: app.slug, + }, + select: { + user: { + select: { + email: true, + locale: true, + }, + }, + }, + }); + + // TODO: This should be done async probably using a queue. + Promise.all( + appCredentials.map(async (credential) => { + // No need to continue if credential does not have a user + if (!credential.user || !credential.user.email) return; + + const locale = credential.user.locale ?? "en"; + let t = translations.get(locale); + + if (!t) { + t = await getTranslation(locale, "common"); + translations.set(locale, t); + } + + await sendDisabledAppEmail({ + email: credential.user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + }); + }) + ); + } else { + const eventTypesWithApp = await prisma.eventType.findMany({ + where: { + metadata: { + path: ["apps", app.slug as string, "enabled"], + equals: true, + }, + }, + select: { + id: true, + title: true, + users: { + select: { + email: true, + locale: true, + }, + }, + metadata: true, + }, + }); + + // TODO: This should be done async probably using a queue. + Promise.all( + eventTypesWithApp.map(async (eventType) => { + // TODO: This update query can be removed by merging it with + // the previous `findMany` query, if that query returns certain values. + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + metadata: { + ...(eventType.metadata as object), + apps: { + // From this comment we can not type JSON fields in Prisma https://github.com/prisma/prisma/issues/3219#issuecomment-670202980 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + ...eventType.metadata?.apps, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + [app.slug]: { ...eventType.metadata?.apps[app.slug], enabled: false }, + }, + }, + }, + }); + + return Promise.all( + eventType.users.map(async (user) => { + const locale = user.locale ?? "en"; + let t = translations.get(locale); + + if (!t) { + t = await getTranslation(locale, "common"); + translations.set(locale, t); + } + + await sendDisabledAppEmail({ + email: user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + title: eventType.title, + eventTypeId: eventType.id, + }); + }) + ); + }) + ); + } + } + + return app.enabled; +}; diff --git a/packages/trpc/server/routers/viewer/apps/toggle.schema.ts b/packages/trpc/server/routers/viewer/apps/toggle.schema.ts new file mode 100644 index 0000000000..8b9c2d2677 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/toggle.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZToggleInputSchema = z.object({ + slug: z.string(), + enabled: z.boolean(), +}); + +export type TToggleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/types.ts b/packages/trpc/server/routers/viewer/apps/types.ts new file mode 100644 index 0000000000..39b5c9a5fa --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/types.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "@calcom/prisma/client"; + +export interface FilteredApp { + name: string; + slug: string; + logo: string; + title?: string; + type: string; + description: string; + dirName: string; + keys: Prisma.JsonObject | null; + enabled: boolean; + isTemplate?: boolean; +} diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts new file mode 100644 index 0000000000..48bd3cd8ef --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts @@ -0,0 +1,49 @@ +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema"; + +type UpdateAppCredentialsOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateAppCredentialsInputSchema; +}; + +export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCredentialsOptions) => { + const { user } = ctx; + + const { key } = input; + + // Find user credential + const credential = await prisma.credential.findFirst({ + where: { + id: input.credentialId, + userId: user.id, + }, + }); + // Check if credential exists + if (!credential) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not find credential ${input.credentialId}`, + }); + } + + const updated = await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: { + ...(credential.key as Prisma.JsonObject), + ...(key as Prisma.JsonObject), + }, + }, + }); + + return !!updated; +}; diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts new file mode 100644 index 0000000000..ad43b5bc50 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZUpdateAppCredentialsInputSchema = z.object({ + credentialId: z.number(), + key: z.object({}).passthrough(), +}); + +export type TUpdateAppCredentialsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth.tsx b/packages/trpc/server/routers/viewer/auth.tsx deleted file mode 100644 index 158472a7b7..0000000000 --- a/packages/trpc/server/routers/viewer/auth.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { IdentityProvider } from "@prisma/client"; -import { z } from "zod"; - -import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; -import { validPassword } from "@calcom/features/auth/lib/validPassword"; -import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; -import prisma from "@calcom/prisma"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -export const authRouter = router({ - changePassword: authedProcedure - .input( - z.object({ - oldPassword: z.string(), - newPassword: z.string(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { oldPassword, newPassword } = input; - - const { user } = ctx; - - if (user.identityProvider !== IdentityProvider.CAL) { - throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" }); - } - - const currentPasswordQuery = await prisma.user.findFirst({ - where: { - id: user.id, - }, - select: { - password: true, - }, - }); - - const currentPassword = currentPasswordQuery?.password; - - if (!currentPassword) { - throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" }); - } - - const passwordsMatch = await verifyPassword(oldPassword, currentPassword); - if (!passwordsMatch) { - throw new TRPCError({ code: "BAD_REQUEST", message: "incorrect_password" }); - } - - if (oldPassword === newPassword) { - throw new TRPCError({ code: "BAD_REQUEST", message: "new_password_matches_old_password" }); - } - - if (!validPassword(newPassword)) { - throw new TRPCError({ code: "BAD_REQUEST", message: "password_hint_min" }); - } - - const hashedPassword = await hashPassword(newPassword); - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - password: hashedPassword, - }, - }); - }), - verifyPassword: authedProcedure - .input( - z.object({ - passwordInput: z.string(), - }) - ) - .mutation(async ({ input, ctx }) => { - const user = await prisma.user.findUnique({ - where: { - id: ctx.user.id, - }, - }); - - if (!user?.password) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - - const passwordsMatch = await verifyPassword(input.passwordInput, user.password); - - if (!passwordsMatch) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return; - }), -}); diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx new file mode 100644 index 0000000000..04f3e72f6b --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -0,0 +1,48 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZChangePasswordInputSchema } from "./changePassword.schema"; +import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema"; + +type AuthRouterHandlerCache = { + changePassword?: typeof import("./changePassword.handler").changePasswordHandler; + verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {}; + +export const authRouter = router({ + changePassword: authedProcedure.input(ZChangePasswordInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.changePassword) { + UNSTABLE_HANDLER_CACHE.changePassword = await import("./changePassword.handler").then( + (mod) => mod.changePasswordHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.changePassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.changePassword({ + ctx, + input, + }); + }), + + verifyPassword: authedProcedure.input(ZVerifyPasswordInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyPassword) { + UNSTABLE_HANDLER_CACHE.verifyPassword = await import("./verifyPassword.handler").then( + (mod) => mod.verifyPasswordHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyPassword) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyPassword({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts new file mode 100644 index 0000000000..ac11fb1187 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts @@ -0,0 +1,66 @@ +import { IdentityProvider } from "@prisma/client"; + +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +import { validPassword } from "@calcom/features/auth/lib/validPassword"; +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TChangePasswordInputSchema } from "./changePassword.schema"; + +type ChangePasswordOptions = { + ctx: { + user: NonNullable; + }; + input: TChangePasswordInputSchema; +}; + +export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOptions) => { + const { oldPassword, newPassword } = input; + + const { user } = ctx; + + if (user.identityProvider !== IdentityProvider.CAL) { + throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" }); + } + + const currentPasswordQuery = await prisma.user.findFirst({ + where: { + id: user.id, + }, + select: { + password: true, + }, + }); + + const currentPassword = currentPasswordQuery?.password; + + if (!currentPassword) { + throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" }); + } + + const passwordsMatch = await verifyPassword(oldPassword, currentPassword); + if (!passwordsMatch) { + throw new TRPCError({ code: "BAD_REQUEST", message: "incorrect_password" }); + } + + if (oldPassword === newPassword) { + throw new TRPCError({ code: "BAD_REQUEST", message: "new_password_matches_old_password" }); + } + + if (!validPassword(newPassword)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "password_hint_min" }); + } + + const hashedPassword = await hashPassword(newPassword); + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + password: hashedPassword, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts b/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts new file mode 100644 index 0000000000..561ba42227 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/changePassword.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZChangePasswordInputSchema = z.object({ + oldPassword: z.string(), + newPassword: z.string(), +}); + +export type TChangePasswordInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts b/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts new file mode 100644 index 0000000000..e663141c33 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/verifyPassword.handler.ts @@ -0,0 +1,34 @@ +import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TVerifyPasswordInputSchema } from "./verifyPassword.schema"; + +type VerifyPasswordOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyPasswordInputSchema; +}; + +export const verifyPasswordHandler = async ({ input, ctx }: VerifyPasswordOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + }); + + if (!user?.password) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const passwordsMatch = await verifyPassword(input.passwordInput, user.password); + + if (!passwordsMatch) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return; +}; diff --git a/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts b/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts new file mode 100644 index 0000000000..5acac97661 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/verifyPassword.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZVerifyPasswordInputSchema = z.object({ + passwordInput: z.string(), +}); + +export type TVerifyPasswordInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability.tsx b/packages/trpc/server/routers/viewer/availability.tsx deleted file mode 100644 index 0c0ab4c46e..0000000000 --- a/packages/trpc/server/routers/viewer/availability.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import type { - Availability as AvailabilityModel, - Prisma, - Schedule as ScheduleModel, - User, -} from "@prisma/client"; -import { z } from "zod"; - -import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import dayjs from "@calcom/dayjs"; -import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule, getWorkingHours } from "@calcom/lib/availability"; -import { yyyymmdd } from "@calcom/lib/date-fns"; -import type { PrismaClient } from "@calcom/prisma/client"; -import { stringOrNumber } from "@calcom/prisma/zod-utils"; -import type { Schedule, TimeRange } from "@calcom/types/schedule"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -export const availabilityRouter = router({ - list: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - - const schedules = await prisma.schedule.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - name: true, - availability: true, - timeZone: true, - }, - orderBy: { - id: "asc", - }, - }); - - const defaultScheduleId = await getDefaultScheduleId(user.id, prisma); - if (!user.defaultScheduleId) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId, - }, - }); - } - - return { - schedules: schedules.map((schedule) => ({ - ...schedule, - isDefault: schedule.id === defaultScheduleId, - })), - }; - }), - user: authedProcedure - .input( - z.object({ - username: z.string(), - dateFrom: z.string(), - dateTo: z.string(), - eventTypeId: stringOrNumber.optional(), - withSource: z.boolean().optional(), - }) - ) - .query(({ input }) => { - return getUserAvailability(input); - }), - schedule: router({ - get: authedProcedure - .input( - z.object({ - scheduleId: z.optional(z.number()), - isManagedEventType: z.optional(z.boolean()), - }) - ) - .query(async ({ ctx, input }) => { - const { prisma, user } = ctx; - const schedule = await prisma.schedule.findUnique({ - where: { - id: input.scheduleId || (await getDefaultScheduleId(user.id, prisma)), - }, - select: { - id: true, - userId: true, - name: true, - availability: true, - timeZone: true, - eventType: { - select: { - _count: true, - id: true, - eventName: true, - }, - }, - }, - }); - if (!schedule || (schedule.userId !== user.id && !input.isManagedEventType)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - const timeZone = schedule.timeZone || user.timeZone; - - const schedulesCount = await ctx.prisma.schedule.count({ - where: { - userId: ctx.user.id, - }, - }); - return { - id: schedule.id, - name: schedule.name, - isManaged: schedule.userId !== user.id, - workingHours: getWorkingHours( - { timeZone: schedule.timeZone || undefined }, - schedule.availability || [] - ), - schedule: schedule.availability, - availability: convertScheduleToAvailability(schedule).map((a) => - a.map((startAndEnd) => ({ - ...startAndEnd, - // Turn our limited granularity into proper end of day. - end: new Date(startAndEnd.end.toISOString().replace("23:59:00.000Z", "23:59:59.999Z")), - })) - ), - timeZone, - dateOverrides: schedule.availability.reduce((acc, override) => { - // only iff future date override - if (!override.date || dayjs.tz(override.date, timeZone).isBefore(dayjs(), "day")) { - return acc; - } - const newValue = { - start: dayjs - .utc(override.date) - .hour(override.startTime.getUTCHours()) - .minute(override.startTime.getUTCMinutes()) - .toDate(), - end: dayjs - .utc(override.date) - .hour(override.endTime.getUTCHours()) - .minute(override.endTime.getUTCMinutes()) - .toDate(), - }; - const dayRangeIndex = acc.findIndex( - // early return prevents override.date from ever being empty. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (item) => yyyymmdd(item.ranges[0].start) === yyyymmdd(override.date!) - ); - if (dayRangeIndex === -1) { - acc.push({ ranges: [newValue] }); - return acc; - } - acc[dayRangeIndex].ranges.push(newValue); - return acc; - }, [] as { ranges: TimeRange[] }[]), - isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, - isLastSchedule: schedulesCount <= 1, - }; - }), - create: authedProcedure - .input( - z.object({ - name: z.string(), - schedule: z - .array( - z.array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - ) - .optional(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - if (input.eventTypeId) { - const eventType = await prisma.eventType.findUnique({ - where: { - id: input.eventTypeId, - }, - select: { - userId: true, - }, - }); - if (!eventType || eventType.userId !== user.id) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to create a schedule for this event type", - }); - } - } - const data: Prisma.ScheduleCreateInput = { - name: input.name, - user: { - connect: { - id: user.id, - }, - }, - // If an eventTypeId is provided then connect the new schedule to that event type - ...(input.eventTypeId && { eventType: { connect: { id: input.eventTypeId } } }), - }; - - const availability = getAvailabilityFromSchedule(input.schedule || DEFAULT_SCHEDULE); - data.availability = { - createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), - }, - }; - - const schedule = await prisma.schedule.create({ - data, - }); - - if (!user.defaultScheduleId) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId: schedule.id, - }, - }); - } - - return { schedule }; - }), - delete: authedProcedure - .input( - z.object({ - scheduleId: z.number(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - - const scheduleToDelete = await prisma.schedule.findFirst({ - where: { - id: input.scheduleId, - }, - select: { - userId: true, - }, - }); - - if (scheduleToDelete?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (user.defaultScheduleId === input.scheduleId) { - // set a new default or unset default if no other schedule - const scheduleToSetAsDefault = await prisma.schedule.findFirst({ - where: { - userId: user.id, - NOT: { - id: input.scheduleId, - }, - }, - select: { - id: true, - }, - }); - - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - defaultScheduleId: scheduleToSetAsDefault?.id || null, - }, - }); - } - await prisma.schedule.delete({ - where: { - id: input.scheduleId, - }, - }); - }), - update: authedProcedure - .input( - z.object({ - scheduleId: z.number(), - timeZone: z.string().optional(), - name: z.string().optional(), - isDefault: z.boolean().optional(), - schedule: z - .array( - z.array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - ) - .optional(), - dateOverrides: z - .array( - z.object({ - start: z.date(), - end: z.date(), - }) - ) - .optional(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { user, prisma } = ctx; - const availability = input.schedule - ? getAvailabilityFromSchedule(input.schedule) - : (input.dateOverrides || []).map((dateOverride) => ({ - startTime: dateOverride.start, - endTime: dateOverride.end, - date: dateOverride.start, - days: [], - })); - - // Not able to update the schedule with userId where clause, so fetch schedule separately and then validate - // Bug: https://github.com/prisma/prisma/issues/7290 - const userSchedule = await prisma.schedule.findUnique({ - where: { - id: input.scheduleId, - }, - select: { - userId: true, - name: true, - id: true, - }, - }); - - if (userSchedule?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (!userSchedule || userSchedule.userId !== user.id) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - - let updatedUser; - if (input.isDefault) { - const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); - updatedUser = setupDefault; - } - - if (!input.name) { - // TODO: Improve - // We don't want to pass the full schedule for just a set as default update - // but in the current logic, this wipes the existing availability. - // Return early to prevent this from happening. - return { - schedule: userSchedule, - isDefault: updatedUser - ? updatedUser.defaultScheduleId === input.scheduleId - : user.defaultScheduleId === input.scheduleId, - }; - } - - const schedule = await prisma.schedule.update({ - where: { - id: input.scheduleId, - }, - data: { - timeZone: input.timeZone, - name: input.name, - availability: { - deleteMany: { - scheduleId: { - equals: input.scheduleId, - }, - }, - createMany: { - data: [ - ...availability, - ...(input.dateOverrides || []).map((override) => ({ - date: override.start, - startTime: override.start, - endTime: override.end, - })), - ], - }, - }, - }, - select: { - id: true, - userId: true, - name: true, - availability: true, - timeZone: true, - eventType: { - select: { - _count: true, - id: true, - eventName: true, - }, - }, - }, - }); - - const userAvailability = convertScheduleToAvailability(schedule); - - return { - schedule, - availability: userAvailability, - timeZone: schedule.timeZone || user.timeZone, - isDefault: updatedUser - ? updatedUser.defaultScheduleId === schedule.id - : user.defaultScheduleId === schedule.id, - prevDefaultId: user.defaultScheduleId, - currentDefaultId: updatedUser ? updatedUser.defaultScheduleId : user.defaultScheduleId, - }; - }), - }), -}); - -export const convertScheduleToAvailability = ( - schedule: Partial & { availability: AvailabilityModel[] } -) => { - return schedule.availability.reduce( - (schedule: Schedule, availability) => { - availability.days.forEach((day) => { - schedule[day].push({ - start: new Date( - Date.UTC( - new Date().getUTCFullYear(), - new Date().getUTCMonth(), - new Date().getUTCDate(), - availability.startTime.getUTCHours(), - availability.startTime.getUTCMinutes() - ) - ), - end: new Date( - Date.UTC( - new Date().getUTCFullYear(), - new Date().getUTCMonth(), - new Date().getUTCDate(), - availability.endTime.getUTCHours(), - availability.endTime.getUTCMinutes() - ) - ), - }); - }); - return schedule; - }, - Array.from([...Array(7)]).map(() => []) - ); -}; - -const setupDefaultSchedule = async (userId: number, scheduleId: number, prisma: PrismaClient) => { - return prisma.user.update({ - where: { - id: userId, - }, - data: { - defaultScheduleId: scheduleId, - }, - }); -}; - -const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - defaultScheduleId: true, - }, - }); - - if (user?.defaultScheduleId) { - return user.defaultScheduleId; - } - - // If we're returning the default schedule for the first time then we should set it in the user record - const defaultSchedule = await prisma.schedule.findFirst({ - where: { - userId, - }, - select: { - id: true, - }, - }); - - return defaultSchedule?.id; // TODO: Handle no schedules AT ALL -}; - -const hasDefaultSchedule = async (user: Partial, prisma: PrismaClient) => { - const defaultSchedule = await prisma.schedule.findFirst({ - where: { - userId: user.id, - }, - }); - return !!user.defaultScheduleId || !!defaultSchedule; -}; diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx new file mode 100644 index 0000000000..e274382ca1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -0,0 +1,45 @@ +import { authedProcedure, router } from "../../../trpc"; +import { scheduleRouter } from "./schedule/_router"; +import { ZUserInputSchema } from "./user.schema"; + +type AvailabilityRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + user?: typeof import("./user.handler").userHandler; +}; + +const UNSTABLE_HANDLER_CACHE: AvailabilityRouterHandlerCache = {}; + +export const availabilityRouter = router({ + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + user: authedProcedure.input(ZUserInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.user) { + UNSTABLE_HANDLER_CACHE.user = await import("./user.handler").then((mod) => mod.userHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.user) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.user({ + ctx, + input, + }); + }), + + schedule: scheduleRouter, +}); diff --git a/packages/trpc/server/routers/viewer/availability/list.handler.ts b/packages/trpc/server/routers/viewer/availability/list.handler.ts new file mode 100644 index 0000000000..80a454d9ff --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/list.handler.ts @@ -0,0 +1,49 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import { getDefaultScheduleId } from "./util"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + const { user } = ctx; + + const schedules = await prisma.schedule.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + availability: true, + timeZone: true, + }, + orderBy: { + id: "asc", + }, + }); + + const defaultScheduleId = await getDefaultScheduleId(user.id, prisma); + + if (!user.defaultScheduleId) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId, + }, + }); + } + + return { + schedules: schedules.map((schedule) => ({ + ...schedule, + isDefault: schedule.id === defaultScheduleId, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/list.schema.ts b/packages/trpc/server/routers/viewer/availability/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx new file mode 100644 index 0000000000..9c1af485ca --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx @@ -0,0 +1,80 @@ +import { authedProcedure, router } from "../../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; + +type ScheduleRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + delete?: typeof import("./delete.handler").deleteHandler; + update?: typeof import("./update.handler").updateHandler; +}; + +const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {}; + +export const scheduleRouter = router({ + get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts new file mode 100644 index 0000000000..4117b996d6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/create.handler.ts @@ -0,0 +1,73 @@ +import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ input, ctx }: CreateOptions) => { + const { user } = ctx; + if (input.eventTypeId) { + const eventType = await prisma.eventType.findUnique({ + where: { + id: input.eventTypeId, + }, + select: { + userId: true, + }, + }); + if (!eventType || eventType.userId !== user.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to create a schedule for this event type", + }); + } + } + const data: Prisma.ScheduleCreateInput = { + name: input.name, + user: { + connect: { + id: user.id, + }, + }, + // If an eventTypeId is provided then connect the new schedule to that event type + ...(input.eventTypeId && { eventType: { connect: { id: input.eventTypeId } } }), + }; + + const availability = getAvailabilityFromSchedule(input.schedule || DEFAULT_SCHEDULE); + data.availability = { + createMany: { + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + })), + }, + }; + + const schedule = await prisma.schedule.create({ + data, + }); + + if (!user.defaultScheduleId) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId: schedule.id, + }, + }); + } + + return { schedule }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts new file mode 100644 index 0000000000..f029a10fa0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/create.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + name: z.string(), + schedule: z + .array( + z.array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + ) + .optional(), + eventTypeId: z.number().optional(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts new file mode 100644 index 0000000000..d77aeecb1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/delete.handler.ts @@ -0,0 +1,57 @@ +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ input, ctx }: DeleteOptions) => { + const { user } = ctx; + + const scheduleToDelete = await prisma.schedule.findFirst({ + where: { + id: input.scheduleId, + }, + select: { + userId: true, + }, + }); + + if (scheduleToDelete?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (user.defaultScheduleId === input.scheduleId) { + // set a new default or unset default if no other schedule + const scheduleToSetAsDefault = await prisma.schedule.findFirst({ + where: { + userId: user.id, + NOT: { + id: input.scheduleId, + }, + }, + select: { + id: true, + }, + }); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + defaultScheduleId: scheduleToSetAsDefault?.id || null, + }, + }); + } + await prisma.schedule.delete({ + where: { + id: input.scheduleId, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts new file mode 100644 index 0000000000..8c8fd5a9b9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + scheduleId: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts new file mode 100644 index 0000000000..40a43f0320 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/get.handler.ts @@ -0,0 +1,100 @@ +import dayjs from "@calcom/dayjs"; +import { getWorkingHours } from "@calcom/lib/availability"; +import { yyyymmdd } from "@calcom/lib/date-fns"; +import { prisma } from "@calcom/prisma"; +import type { TimeRange } from "@calcom/types/schedule"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import { convertScheduleToAvailability, getDefaultScheduleId } from "../util"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const { user } = ctx; + + const schedule = await prisma.schedule.findUnique({ + where: { + id: input.scheduleId || (await getDefaultScheduleId(user.id, prisma)), + }, + select: { + id: true, + userId: true, + name: true, + availability: true, + timeZone: true, + eventType: { + select: { + _count: true, + id: true, + eventName: true, + }, + }, + }, + }); + if (!schedule || (schedule.userId !== user.id && !input.isManagedEventType)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + const timeZone = schedule.timeZone || user.timeZone; + + const schedulesCount = await prisma.schedule.count({ + where: { + userId: user.id, + }, + }); + return { + id: schedule.id, + name: schedule.name, + isManaged: schedule.userId !== user.id, + workingHours: getWorkingHours({ timeZone: schedule.timeZone || undefined }, schedule.availability || []), + schedule: schedule.availability, + availability: convertScheduleToAvailability(schedule).map((a) => + a.map((startAndEnd) => ({ + ...startAndEnd, + // Turn our limited granularity into proper end of day. + end: new Date(startAndEnd.end.toISOString().replace("23:59:00.000Z", "23:59:59.999Z")), + })) + ), + timeZone, + dateOverrides: schedule.availability.reduce((acc, override) => { + // only iff future date override + if (!override.date || dayjs.tz(override.date, timeZone).isBefore(dayjs(), "day")) { + return acc; + } + const newValue = { + start: dayjs + .utc(override.date) + .hour(override.startTime.getUTCHours()) + .minute(override.startTime.getUTCMinutes()) + .toDate(), + end: dayjs + .utc(override.date) + .hour(override.endTime.getUTCHours()) + .minute(override.endTime.getUTCMinutes()) + .toDate(), + }; + const dayRangeIndex = acc.findIndex( + // early return prevents override.date from ever being empty. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (item) => yyyymmdd(item.ranges[0].start) === yyyymmdd(override.date!) + ); + if (dayRangeIndex === -1) { + acc.push({ ranges: [newValue] }); + return acc; + } + acc[dayRangeIndex].ranges.push(newValue); + return acc; + }, [] as { ranges: TimeRange[] }[]), + isDefault: !input.scheduleId || user.defaultScheduleId === schedule.id, + isLastSchedule: schedulesCount <= 1, + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts new file mode 100644 index 0000000000..3e83bce0fd --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/get.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + scheduleId: z.optional(z.number()), + isManagedEventType: z.optional(z.boolean()), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts new file mode 100644 index 0000000000..e5d7cf4d3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/update.handler.ts @@ -0,0 +1,121 @@ +import { getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../../trpc"; +import { convertScheduleToAvailability, setupDefaultSchedule } from "../util"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ input, ctx }: UpdateOptions) => { + const { user } = ctx; + const availability = input.schedule + ? getAvailabilityFromSchedule(input.schedule) + : (input.dateOverrides || []).map((dateOverride) => ({ + startTime: dateOverride.start, + endTime: dateOverride.end, + date: dateOverride.start, + days: [], + })); + + // Not able to update the schedule with userId where clause, so fetch schedule separately and then validate + // Bug: https://github.com/prisma/prisma/issues/7290 + const userSchedule = await prisma.schedule.findUnique({ + where: { + id: input.scheduleId, + }, + select: { + userId: true, + name: true, + id: true, + }, + }); + + if (userSchedule?.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (!userSchedule || userSchedule.userId !== user.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + + let updatedUser; + if (input.isDefault) { + const setupDefault = await setupDefaultSchedule(user.id, input.scheduleId, prisma); + updatedUser = setupDefault; + } + + if (!input.name) { + // TODO: Improve + // We don't want to pass the full schedule for just a set as default update + // but in the current logic, this wipes the existing availability. + // Return early to prevent this from happening. + return { + schedule: userSchedule, + isDefault: updatedUser + ? updatedUser.defaultScheduleId === input.scheduleId + : user.defaultScheduleId === input.scheduleId, + }; + } + + const schedule = await prisma.schedule.update({ + where: { + id: input.scheduleId, + }, + data: { + timeZone: input.timeZone, + name: input.name, + availability: { + deleteMany: { + scheduleId: { + equals: input.scheduleId, + }, + }, + createMany: { + data: [ + ...availability, + ...(input.dateOverrides || []).map((override) => ({ + date: override.start, + startTime: override.start, + endTime: override.end, + })), + ], + }, + }, + }, + select: { + id: true, + userId: true, + name: true, + availability: true, + timeZone: true, + eventType: { + select: { + _count: true, + id: true, + eventName: true, + }, + }, + }, + }); + + const userAvailability = convertScheduleToAvailability(schedule); + + return { + schedule, + availability: userAvailability, + timeZone: schedule.timeZone || user.timeZone, + isDefault: updatedUser + ? updatedUser.defaultScheduleId === schedule.id + : user.defaultScheduleId === schedule.id, + prevDefaultId: user.defaultScheduleId, + currentDefaultId: updatedUser ? updatedUser.defaultScheduleId : user.defaultScheduleId, + }; +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts new file mode 100644 index 0000000000..e37588842c --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/update.schema.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + scheduleId: z.number(), + timeZone: z.string().optional(), + name: z.string().optional(), + isDefault: z.boolean().optional(), + schedule: z + .array( + z.array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + ) + .optional(), + dateOverrides: z + .array( + z.object({ + start: z.date(), + end: z.date(), + }) + ) + .optional(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts new file mode 100644 index 0000000000..d45c2d85c0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -0,0 +1,12 @@ +import { getUserAvailability } from "@calcom/core/getUserAvailability"; + +import type { TUserInputSchema } from "./user.schema"; + +type UserOptions = { + ctx: Record; + input: TUserInputSchema; +}; + +export const userHandler = async ({ input }: UserOptions) => { + return getUserAvailability(input); +}; diff --git a/packages/trpc/server/routers/viewer/availability/user.schema.ts b/packages/trpc/server/routers/viewer/availability/user.schema.ts new file mode 100644 index 0000000000..c5920e81dd --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/user.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { stringOrNumber } from "@calcom/prisma/zod-utils"; + +export const ZUserInputSchema = z.object({ + username: z.string(), + dateFrom: z.string(), + dateTo: z.string(), + eventTypeId: stringOrNumber.optional(), + withSource: z.boolean().optional(), +}); + +export type TUserInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/util.ts b/packages/trpc/server/routers/viewer/availability/util.ts new file mode 100644 index 0000000000..94b3621274 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/util.ts @@ -0,0 +1,84 @@ +import type { Availability as AvailabilityModel, Schedule as ScheduleModel, User } from "@prisma/client"; + +import type { PrismaClient } from "@calcom/prisma/client"; +import type { Schedule } from "@calcom/types/schedule"; + +export const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + defaultScheduleId: true, + }, + }); + + if (user?.defaultScheduleId) { + return user.defaultScheduleId; + } + + // If we're returning the default schedule for the first time then we should set it in the user record + const defaultSchedule = await prisma.schedule.findFirst({ + where: { + userId, + }, + select: { + id: true, + }, + }); + + return defaultSchedule?.id; // TODO: Handle no schedules AT ALL +}; + +export const hasDefaultSchedule = async (user: Partial, prisma: PrismaClient) => { + const defaultSchedule = await prisma.schedule.findFirst({ + where: { + userId: user.id, + }, + }); + return !!user.defaultScheduleId || !!defaultSchedule; +}; + +export const convertScheduleToAvailability = ( + schedule: Partial & { availability: AvailabilityModel[] } +) => { + return schedule.availability.reduce( + (schedule: Schedule, availability) => { + availability.days.forEach((day) => { + schedule[day].push({ + start: new Date( + Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate(), + availability.startTime.getUTCHours(), + availability.startTime.getUTCMinutes() + ) + ), + end: new Date( + Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate(), + availability.endTime.getUTCHours(), + availability.endTime.getUTCMinutes() + ) + ), + }); + }); + return schedule; + }, + Array.from([...Array(7)]).map(() => []) + ); +}; + +export const setupDefaultSchedule = async (userId: number, scheduleId: number, prisma: PrismaClient) => { + return prisma.user.update({ + where: { + id: userId, + }, + data: { + defaultScheduleId: scheduleId, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx deleted file mode 100644 index fccc6460ed..0000000000 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ /dev/null @@ -1,1039 +0,0 @@ -import { BookingStatus, MembershipRole, Prisma, SchedulingType, WorkflowMethods } from "@prisma/client"; -import type { BookingReference, EventType, User, WebhookTriggerEvents } from "@prisma/client"; -import type { TFunction } from "next-i18next"; -import { z } from "zod"; - -import appStore from "@calcom/app-store"; -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { DailyLocationType } from "@calcom/app-store/locations"; -import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; -import EventManager from "@calcom/core/EventManager"; -import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; -import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; -import { deleteMeeting } from "@calcom/core/videoClient"; -import dayjs from "@calcom/dayjs"; -import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; -import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; -import { sendDeclinedEmails, sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails"; -import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; -import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/lib/server"; -import { bookingMinimalSelect } from "@calcom/prisma"; -import { bookingConfirmPatchBodySchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -export type PersonAttendeeCommonFields = Pick< - User, - "id" | "email" | "name" | "locale" | "timeZone" | "username" ->; - -// Common data for all endpoints under webhook -const commonBookingSchema = z.object({ - bookingId: z.number(), -}); - -const bookingsProcedure = authedProcedure.input(commonBookingSchema).use(async ({ ctx, input, next }) => { - // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input - const { bookingId } = input; - const booking = await ctx.prisma.booking.findFirst({ - where: { - id: bookingId, - AND: [ - { - OR: [ - /* If user is organizer */ - { userId: ctx.user.id }, - /* Or part of a collective booking */ - { - eventType: { - schedulingType: SchedulingType.COLLECTIVE, - users: { - some: { - id: ctx.user.id, - }, - }, - }, - }, - ], - }, - ], - }, - include: { - attendees: true, - eventType: true, - destinationCalendar: true, - references: true, - user: { - include: { - destinationCalendar: true, - credentials: true, - }, - }, - }, - }); - - if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" }); - - return next({ ctx: { booking } }); -}); - -export const bookingsRouter = router({ - get: authedProcedure - .input( - z.object({ - filters: z.object({ - teamIds: z.number().array().optional(), - userIds: z.number().array().optional(), - status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), - eventTypeIds: z.number().array().optional(), - }), - limit: z.number().min(1).max(100).nullish(), - cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type - }) - ) - .query(async ({ ctx, input }) => { - // using offset actually because cursor pagination requires a unique column - // for orderBy, but we don't use a unique column in our orderBy - const take = input.limit ?? 10; - const skip = input.cursor ?? 0; - const { prisma, user } = ctx; - const bookingListingByStatus = input.filters.status; - const bookingListingFilters: Record = { - upcoming: { - endTime: { gte: new Date() }, - // These changes are needed to not show confirmed recurring events, - // as rescheduling or cancel for recurring event bookings should be - // handled separately for each occurrence - OR: [ - { - recurringEventId: { not: null }, - status: { equals: BookingStatus.ACCEPTED }, - }, - { - recurringEventId: { equals: null }, - status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] }, - }, - ], - }, - recurring: { - endTime: { gte: new Date() }, - AND: [ - { NOT: { recurringEventId: { equals: null } } }, - { status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } }, - ], - }, - past: { - endTime: { lte: new Date() }, - AND: [ - { NOT: { status: { equals: BookingStatus.CANCELLED } } }, - { NOT: { status: { equals: BookingStatus.REJECTED } } }, - ], - }, - cancelled: { - OR: [ - { status: { equals: BookingStatus.CANCELLED } }, - { status: { equals: BookingStatus.REJECTED } }, - ], - }, - unconfirmed: { - endTime: { gte: new Date() }, - status: { equals: BookingStatus.PENDING }, - }, - }; - const bookingListingOrderby: Record< - typeof bookingListingByStatus, - Prisma.BookingOrderByWithAggregationInput - > = { - upcoming: { startTime: "asc" }, - recurring: { startTime: "asc" }, - past: { startTime: "desc" }, - cancelled: { startTime: "desc" }, - unconfirmed: { startTime: "asc" }, - }; - - // TODO: Fix record typing - const bookingWhereInputFilters: Record = { - teamIds: { - AND: [ - { - eventType: { - team: { - id: { - in: input.filters?.teamIds, - }, - }, - }, - }, - ], - }, - userIds: { - AND: [ - { - eventType: { - users: { - some: { - id: { - in: input.filters?.userIds, - }, - }, - }, - }, - }, - ], - }, - }; - - const filtersCombined: Prisma.BookingWhereInput[] = - input.filters && - Object.keys(input.filters).map((key) => { - return bookingWhereInputFilters[key]; - }); - - const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; - const orderBy = bookingListingOrderby[bookingListingByStatus]; - - const [bookingsQuery, recurringInfoBasic, recurringInfoExtended] = await Promise.all([ - prisma.booking.findMany({ - where: { - OR: [ - { - userId: user.id, - }, - { - attendees: { - some: { - email: user.email, - }, - }, - }, - { - eventType: { - team: { - members: { - some: { - userId: user.id, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }, - }, - }, - }, - { - seatsReferences: { - some: { - attendee: { - email: user.email, - }, - }, - }, - }, - ], - AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])], - }, - select: { - ...bookingMinimalSelect, - uid: true, - recurringEventId: true, - location: true, - eventType: { - select: { - slug: true, - id: true, - eventName: true, - price: true, - recurringEvent: true, - currency: true, - metadata: true, - team: { - select: { - name: true, - }, - }, - }, - }, - status: true, - paid: true, - payment: { - select: { - paymentOption: true, - amount: true, - currency: true, - success: true, - }, - }, - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - rescheduled: true, - references: true, - isRecorded: true, - seatsReferences: { - where: { - attendee: { - email: user.email, - }, - }, - select: { - referenceUid: true, - attendee: { - select: { - email: true, - }, - }, - }, - }, - }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.groupBy({ - by: ["recurringEventId"], - _min: { - startTime: true, - }, - _count: { - recurringEventId: true, - }, - where: { - recurringEventId: { - not: { equals: null }, - }, - userId: user.id, - }, - }), - prisma.booking.groupBy({ - by: ["recurringEventId", "status", "startTime"], - _min: { - startTime: true, - }, - where: { - recurringEventId: { - not: { equals: null }, - }, - userId: user.id, - }, - }), - ]); - - const recurringInfo = recurringInfoBasic.map( - ( - info: (typeof recurringInfoBasic)[number] - ): { - recurringEventId: string | null; - count: number; - firstDate: Date | null; - bookings: { - [key: string]: Date[]; - }; - } => { - const bookings = recurringInfoExtended.reduce( - (prev, curr) => { - if (curr.recurringEventId === info.recurringEventId) { - prev[curr.status].push(curr.startTime); - } - return prev; - }, - { ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [] } as { - [key in BookingStatus]: Date[]; - } - ); - return { - recurringEventId: info.recurringEventId, - count: info._count.recurringEventId, - firstDate: info._min.startTime, - bookings, - }; - } - ); - - const bookings = bookingsQuery.map((booking) => { - return { - ...booking, - eventType: { - ...booking.eventType, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - price: booking.eventType?.price || 0, - currency: booking.eventType?.currency || "usd", - metadata: EventTypeMetaDataSchema.parse(booking.eventType?.metadata || {}), - }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - }; - }); - - const bookingsFetched = bookings.length; - let nextCursor: typeof skip | null = skip; - if (bookingsFetched > take) { - nextCursor += bookingsFetched; - } else { - nextCursor = null; - } - - return { - bookings, - recurringInfo, - nextCursor, - }; - }), - requestReschedule: authedProcedure - .input( - z.object({ - bookingId: z.string(), - rescheduleReason: z.string().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { bookingId, rescheduleReason: cancellationReason } = input; - - const bookingToReschedule = await prisma.booking.findFirstOrThrow({ - select: { - id: true, - uid: true, - userId: true, - title: true, - description: true, - startTime: true, - endTime: true, - eventTypeId: true, - eventType: true, - location: true, - attendees: true, - references: true, - customInputs: true, - dynamicEventSlugRef: true, - dynamicGroupSlugRef: true, - destinationCalendar: true, - smsReminderNumber: true, - scheduledJobs: true, - workflowReminders: true, - responses: true, - }, - where: { - uid: bookingId, - NOT: { - status: { - in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], - }, - }, - }, - }); - - if (!bookingToReschedule.userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); - } - - if (!bookingToReschedule.eventType) { - throw new TRPCError({ code: "FORBIDDEN", message: "EventType not found for current booking." }); - } - - const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; - - const userTeams = await prisma.user.findUniqueOrThrow({ - where: { - id: user.id, - }, - select: { - teams: true, - }, - }); - - if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { - const userTeamIds = userTeams.teams.map((item) => item.teamId); - if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { - throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); - } - } - if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); - } - - if (bookingToReschedule) { - let event: Partial = {}; - if (bookingToReschedule.eventTypeId) { - event = await prisma.eventType.findFirstOrThrow({ - select: { - title: true, - users: true, - schedulingType: true, - recurringEvent: true, - }, - where: { - id: bookingToReschedule.eventTypeId, - }, - }); - } - await prisma.booking.update({ - where: { - id: bookingToReschedule.id, - }, - data: { - rescheduled: true, - cancellationReason, - status: BookingStatus.CANCELLED, - updatedAt: dayjs().toISOString(), - }, - }); - - // delete scheduled jobs of previous booking - cancelScheduledJobs(bookingToReschedule); - - //cancel workflow reminders of previous booking - bookingToReschedule.workflowReminders.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - const [mainAttendee] = bookingToReschedule.attendees; - // @NOTE: Should we assume attendees language? - const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); - const usersToPeopleType = ( - users: PersonAttendeeCommonFields[], - selectedLanguage: TFunction - ): Person[] => { - return users?.map((user) => { - return { - email: user.email || "", - name: user.name || "", - username: user?.username || "", - language: { translate: selectedLanguage, locale: user.locale || "en" }, - timeZone: user?.timeZone, - }; - }); - }; - - const userTranslation = await getTranslation(user.locale ?? "en", "common"); - const [userAsPeopleType] = usersToPeopleType([user], userTranslation); - - const builder = new CalendarEventBuilder(); - builder.init({ - title: bookingToReschedule.title, - type: event && event.title ? event.title : bookingToReschedule.title, - startTime: bookingToReschedule.startTime.toISOString(), - endTime: bookingToReschedule.endTime.toISOString(), - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - organizer: userAsPeopleType, - }); - - const director = new CalendarEventDirector(); - director.setBuilder(builder); - director.setExistingBooking(bookingToReschedule); - cancellationReason && director.setCancellationReason(cancellationReason); - if (event) { - await director.buildForRescheduleEmail(); - } else { - await director.buildWithoutEventTypeForRescheduleEmail(); - } - - // Handling calendar and videos cancellation - // This can set previous time as available, until virtual calendar is done - const credentialsMap = new Map(); - user.credentials.forEach((credential) => { - credentialsMap.set(credential.type, credential); - }); - const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) => - credentialsMap.has(ref.type) - ); - bookingRefsFiltered.forEach(async (bookingRef) => { - if (bookingRef.uid) { - if (bookingRef.type.endsWith("_calendar")) { - const calendar = await getCalendar(credentialsMap.get(bookingRef.type)); - - return calendar?.deleteEvent( - bookingRef.uid, - builder.calendarEvent, - bookingRef.externalCalendarId - ); - } else if (bookingRef.type.endsWith("_video")) { - return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); - } - } - }); - - // Send emails - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); - - const evt: CalendarEvent = { - title: bookingToReschedule?.title, - type: event && event.title ? event.title : bookingToReschedule.title, - description: bookingToReschedule?.description || "", - customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), - ...getCalEventResponses({ - booking: bookingToReschedule, - bookingFields: bookingToReschedule.eventType?.bookingFields ?? null, - }), - startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", - endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", - organizer: userAsPeopleType, - attendees: usersToPeopleType( - // username field doesn't exists on attendee but could be in the future - bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], - tAttendees - ), - uid: bookingToReschedule?.uid, - location: bookingToReschedule?.location, - destinationCalendar: - bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, - cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this - }; - - // Send webhook - const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; - // Send Webhook call if hooked to BOOKING.CANCELLED - const subscriberOptions = { - userId: bookingToReschedule.userId, - eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, - triggerEvent: eventTrigger, - }; - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => - sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { - ...evt, - smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined, - }).catch((e) => { - console.error( - `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, - e - ); - }) - ); - await Promise.all(promises); - } - }), - editLocation: bookingsProcedure - .input( - commonBookingSchema.extend({ - newLocation: z.string().transform((val) => val || DailyLocationType), - }) - ) - - .mutation(async ({ ctx, input }) => { - const { bookingId, newLocation: location } = input; - const { booking } = ctx; - - try { - const organizer = await ctx.prisma.user.findFirstOrThrow({ - where: { - id: booking.userId || 0, - }, - select: { - name: true, - email: true, - timeZone: true, - locale: true, - }, - }); - - const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); - - const attendeesListPromises = booking.attendees.map(async (attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - - const evt: CalendarEvent = { - title: booking.title || "", - type: (booking.eventType?.title as string) || booking?.title || "", - description: booking.description || "", - startTime: booking.startTime ? dayjs(booking.startTime).format() : "", - endTime: booking.endTime ? dayjs(booking.endTime).format() : "", - organizer: { - email: organizer.email, - name: organizer.name ?? "Nameless", - timeZone: organizer.timeZone, - language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, - }, - attendees: attendeesList, - uid: booking.uid, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - location, - destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar, - seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, - seatsShowAttendees: booking.eventType?.seatsShowAttendees, - }; - - const eventManager = new EventManager(ctx.user); - - const updatedResult = await eventManager.updateLocation(evt, booking); - const results = updatedResult.results; - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingUpdateLocationFailed", - message: "Updating location failed", - }; - logger.error(`Booking ${ctx.user.username} failed`, error, results); - } else { - await ctx.prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - location, - references: { - create: updatedResult.referencesToCreate, - }, - }, - }); - - const metadata: AdditionalInformation = {}; - if (results.length) { - metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; - metadata.conferenceData = results[0].updatedEvent?.conferenceData; - metadata.entryPoints = results[0].updatedEvent?.entryPoints; - } - try { - await sendLocationChangeEmails({ ...evt, additionalInformation: metadata }); - } catch (error) { - console.log("Error sending LocationChangeEmails"); - } - } - } catch { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - return { message: "Location updated" }; - }), - confirm: bookingsProcedure.input(bookingConfirmPatchBodySchema).mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input; - - const tOrganizer = await getTranslation(user.locale ?? "en", "common"); - - const booking = await prisma.booking.findUniqueOrThrow({ - where: { - id: bookingId, - }, - select: { - title: true, - description: true, - customInputs: true, - startTime: true, - endTime: true, - attendees: true, - eventTypeId: true, - responses: true, - eventType: { - select: { - id: true, - owner: true, - teamId: true, - recurringEvent: true, - title: true, - requiresConfirmation: true, - currency: true, - length: true, - description: true, - price: true, - bookingFields: true, - disableGuests: true, - metadata: true, - workflows: { - include: { - workflow: { - include: { - steps: true, - }, - }, - }, - }, - customInputs: true, - }, - }, - location: true, - userId: true, - id: true, - uid: true, - payment: true, - destinationCalendar: true, - paid: true, - recurringEventId: true, - status: true, - smsReminderNumber: true, - scheduledJobs: true, - }, - }); - - if (booking.userId !== user.id && booking.eventTypeId) { - // Only query database when it is explicitly required. - const eventType = await prisma.eventType.findFirst({ - where: { - id: booking.eventTypeId, - schedulingType: SchedulingType.COLLECTIVE, - }, - select: { - users: true, - }, - }); - - if (eventType && !eventType.users.find((user) => booking.userId === user.id)) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" }); - } - } - - // Do not move this before authorization check. - // This is done to avoid exposing extra information to the requester. - if (booking.status === BookingStatus.ACCEPTED) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); - } - - // If booking requires payment and is not paid, we don't allow confirmation - if (confirmed && booking.payment.length > 0 && !booking.paid) { - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.ACCEPTED, - }, - }); - - return { message: "Booking confirmed", status: BookingStatus.ACCEPTED }; - } - - // Cache translations to avoid requesting multiple times. - const translations = new Map(); - const attendeesListPromises = booking.attendees.map(async (attendee) => { - const locale = attendee.locale ?? "en"; - let translate = translations.get(locale); - if (!translate) { - translate = await getTranslation(locale, "common"); - translations.set(locale, translate); - } - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate, - locale, - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - - const evt: CalendarEvent = { - type: booking.eventType?.title || booking.title, - title: booking.title, - description: booking.description, - // TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted. - ...getCalEventResponses({ - bookingFields: booking.eventType?.bookingFields ?? null, - booking, - }), - customInputs: isPrismaObjOrUndefined(booking.customInputs), - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - email: user.email, - name: user.name || "Unnamed", - username: user.username || undefined, - timeZone: user.timeZone, - language: { translate: tOrganizer, locale: user.locale ?? "en" }, - }, - attendees: attendeesList, - location: booking.location ?? "", - uid: booking.uid, - destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, - requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, - eventTypeId: booking.eventType?.id, - }; - - const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); - if (recurringEventId) { - if ( - !(await prisma.booking.findFirst({ - where: { - recurringEventId, - id: booking.id, - }, - select: { - id: true, - }, - })) - ) { - // FIXME: It might be best to retrieve recurringEventId from the booking itself. - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Recurring event id doesn't belong to the booking", - }); - } - } - if (recurringEventId && recurringEvent) { - const groupedRecurringBookings = await prisma.booking.groupBy({ - where: { - recurringEventId: booking.recurringEventId, - }, - by: [Prisma.BookingScalarFieldEnum.recurringEventId], - _count: true, - }); - // Overriding the recurring event configuration count to be the actual number of events booked for - // the recurring event (equal or less than recurring event configuration count) - recurringEvent.count = groupedRecurringBookings[0]._count; - // count changed, parsing again to get the new value in - evt.recurringEvent = parseRecurringEvent(recurringEvent); - } - - if (confirmed) { - await handleConfirmation({ user, evt, recurringEventId, prisma, bookingId, booking }); - } else { - evt.rejectionReason = rejectionReason; - if (recurringEventId) { - // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related - // bookings as rejected. - await prisma.booking.updateMany({ - where: { - recurringEventId, - status: BookingStatus.PENDING, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } else { - // handle refunds - if (!!booking.payment.length) { - const successPayment = booking.payment.find((payment) => payment.success); - if (!successPayment) { - // Disable paymentLink for this booking - } else { - let eventTypeOwnerId; - if (booking.eventType?.owner) { - eventTypeOwnerId = booking.eventType.owner.id; - } else if (booking.eventType?.teamId) { - const teamOwner = await prisma.membership.findFirst({ - where: { - teamId: booking.eventType.teamId, - role: MembershipRole.OWNER, - }, - select: { - userId: true, - }, - }); - eventTypeOwnerId = teamOwner?.userId; - } - - if (!eventTypeOwnerId) { - throw new Error("Event Type owner not found for obtaining payment app credentials"); - } - - const paymentAppCredentials = await prisma.credential.findMany({ - where: { - userId: eventTypeOwnerId, - appId: successPayment.appId, - }, - select: { - key: true, - appId: true, - app: { - select: { - categories: true, - dirName: true, - }, - }, - }, - }); - - const paymentAppCredential = paymentAppCredentials.find((credential) => { - return credential.appId === successPayment.appId; - }); - - if (!paymentAppCredential) { - throw new Error("Payment app credentials not found"); - } - - // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { - console.warn(`payment App service of type ${paymentApp} is not implemented`); - return null; - } - - const PaymentService = paymentApp.lib.PaymentService; - const paymentInstance = new PaymentService(paymentAppCredential); - const paymentData = await paymentInstance.refund(successPayment.id); - if (!paymentData.refunded) { - throw new Error("Payment could not be refunded"); - } - } - } - // end handle refunds. - - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - status: BookingStatus.REJECTED, - rejectionReason, - }, - }); - } - - await sendDeclinedEmails(evt); - } - - const message = "Booking " + confirmed ? "confirmed" : "rejected"; - const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED; - - return { message, status }; - }), - getBookingAttendees: authedProcedure - .input(z.object({ seatReferenceUid: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const bookingSeat = await ctx.prisma.bookingSeat.findUniqueOrThrow({ - where: { - referenceUid: input.seatReferenceUid, - }, - select: { - booking: { - select: { - _count: { - select: { - seatsReferences: true, - }, - }, - }, - }, - }, - }); - - if (!bookingSeat) { - throw new Error("Booking not found"); - } - return bookingSeat.booking._count.seatsReferences; - }), -}); diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx new file mode 100644 index 0000000000..e539268686 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -0,0 +1,107 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZConfirmInputSchema } from "./confirm.schema"; +import { ZEditLocationInputSchema } from "./editLocation.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; +import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; +import { bookingsProcedure } from "./util"; + +type BookingsRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; + editLocation?: typeof import("./editLocation.handler").editLocationHandler; + confirm?: typeof import("./confirm.handler").confirmHandler; + getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; + +export const bookingsRouter = router({ + get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + requestReschedule: authedProcedure.input(ZRequestRescheduleInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.requestReschedule) { + UNSTABLE_HANDLER_CACHE.requestReschedule = await import("./requestReschedule.handler").then( + (mod) => mod.requestRescheduleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.requestReschedule) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.requestReschedule({ + ctx, + input, + }); + }), + + editLocation: bookingsProcedure.input(ZEditLocationInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.editLocation) { + UNSTABLE_HANDLER_CACHE.editLocation = await import("./editLocation.handler").then( + (mod) => mod.editLocationHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.editLocation) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.editLocation({ + ctx, + input, + }); + }), + + confirm: bookingsProcedure.input(ZConfirmInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.confirm) { + UNSTABLE_HANDLER_CACHE.confirm = await import("./confirm.handler").then((mod) => mod.confirmHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.confirm) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.confirm({ + ctx, + input, + }); + }), + + getBookingAttendees: authedProcedure + .input(ZGetBookingAttendeesInputSchema) + .query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getBookingAttendees) { + UNSTABLE_HANDLER_CACHE.getBookingAttendees = await import("./getBookingAttendees.handler").then( + (mod) => mod.getBookingAttendeesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getBookingAttendees) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getBookingAttendees({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts new file mode 100644 index 0000000000..cde45e620a --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -0,0 +1,310 @@ +import { BookingStatus, MembershipRole, Prisma, SchedulingType } from "@prisma/client"; + +import appStore from "@calcom/app-store"; +import { sendDeclinedEmails } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TConfirmInputSchema } from "./confirm.schema"; +import type { BookingsProcedureContext } from "./util"; + +type ConfirmOptions = { + ctx: { + user: NonNullable; + } & BookingsProcedureContext; + input: TConfirmInputSchema; +}; + +export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { + const { user } = ctx; + const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input; + + const tOrganizer = await getTranslation(user.locale ?? "en", "common"); + + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + id: bookingId, + }, + select: { + title: true, + description: true, + customInputs: true, + startTime: true, + endTime: true, + attendees: true, + eventTypeId: true, + responses: true, + eventType: { + select: { + id: true, + owner: true, + teamId: true, + recurringEvent: true, + title: true, + requiresConfirmation: true, + currency: true, + length: true, + description: true, + price: true, + bookingFields: true, + disableGuests: true, + metadata: true, + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + customInputs: true, + }, + }, + location: true, + userId: true, + id: true, + uid: true, + payment: true, + destinationCalendar: true, + paid: true, + recurringEventId: true, + status: true, + smsReminderNumber: true, + scheduledJobs: true, + }, + }); + + if (booking.userId !== user.id && booking.eventTypeId) { + // Only query database when it is explicitly required. + const eventType = await prisma.eventType.findFirst({ + where: { + id: booking.eventTypeId, + schedulingType: SchedulingType.COLLECTIVE, + }, + select: { + users: true, + }, + }); + + if (eventType && !eventType.users.find((user) => booking.userId === user.id)) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" }); + } + } + + // Do not move this before authorization check. + // This is done to avoid exposing extra information to the requester. + if (booking.status === BookingStatus.ACCEPTED) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); + } + + // If booking requires payment and is not paid, we don't allow confirmation + if (confirmed && booking.payment.length > 0 && !booking.paid) { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.ACCEPTED, + }, + }); + + return { message: "Booking confirmed", status: BookingStatus.ACCEPTED }; + } + + // Cache translations to avoid requesting multiple times. + const translations = new Map(); + const attendeesListPromises = booking.attendees.map(async (attendee) => { + const locale = attendee.locale ?? "en"; + let translate = translations.get(locale); + if (!translate) { + translate = await getTranslation(locale, "common"); + translations.set(locale, translate); + } + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate, + locale, + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + type: booking.eventType?.title || booking.title, + title: booking.title, + description: booking.description, + // TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted. + ...getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + booking, + }), + customInputs: isPrismaObjOrUndefined(booking.customInputs), + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: user.email, + name: user.name || "Unnamed", + username: user.username || undefined, + timeZone: user.timeZone, + language: { translate: tOrganizer, locale: user.locale ?? "en" }, + }, + attendees: attendeesList, + location: booking.location ?? "", + uid: booking.uid, + destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, + requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, + eventTypeId: booking.eventType?.id, + }; + + const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); + if (recurringEventId) { + if ( + !(await prisma.booking.findFirst({ + where: { + recurringEventId, + id: booking.id, + }, + select: { + id: true, + }, + })) + ) { + // FIXME: It might be best to retrieve recurringEventId from the booking itself. + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Recurring event id doesn't belong to the booking", + }); + } + } + if (recurringEventId && recurringEvent) { + const groupedRecurringBookings = await prisma.booking.groupBy({ + where: { + recurringEventId: booking.recurringEventId, + }, + by: [Prisma.BookingScalarFieldEnum.recurringEventId], + _count: true, + }); + // Overriding the recurring event configuration count to be the actual number of events booked for + // the recurring event (equal or less than recurring event configuration count) + recurringEvent.count = groupedRecurringBookings[0]._count; + // count changed, parsing again to get the new value in + evt.recurringEvent = parseRecurringEvent(recurringEvent); + } + + if (confirmed) { + await handleConfirmation({ user, evt, recurringEventId, prisma, bookingId, booking }); + } else { + evt.rejectionReason = rejectionReason; + if (recurringEventId) { + // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related + // bookings as rejected. + await prisma.booking.updateMany({ + where: { + recurringEventId, + status: BookingStatus.PENDING, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } else { + // handle refunds + if (!!booking.payment.length) { + const successPayment = booking.payment.find((payment) => payment.success); + if (!successPayment) { + // Disable paymentLink for this booking + } else { + let eventTypeOwnerId; + if (booking.eventType?.owner) { + eventTypeOwnerId = booking.eventType.owner.id; + } else if (booking.eventType?.teamId) { + const teamOwner = await prisma.membership.findFirst({ + where: { + teamId: booking.eventType.teamId, + role: MembershipRole.OWNER, + }, + select: { + userId: true, + }, + }); + eventTypeOwnerId = teamOwner?.userId; + } + + if (!eventTypeOwnerId) { + throw new Error("Event Type owner not found for obtaining payment app credentials"); + } + + const paymentAppCredentials = await prisma.credential.findMany({ + where: { + userId: eventTypeOwnerId, + appId: successPayment.appId, + }, + select: { + key: true, + appId: true, + app: { + select: { + categories: true, + dirName: true, + }, + }, + }, + }); + + const paymentAppCredential = paymentAppCredentials.find((credential) => { + return credential.appId === successPayment.appId; + }); + + if (!paymentAppCredential) { + throw new Error("Payment app credentials not found"); + } + + // Posible to refactor TODO: + const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; + if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + console.warn(`payment App service of type ${paymentApp} is not implemented`); + return null; + } + + const PaymentService = paymentApp.lib.PaymentService; + const paymentInstance = new PaymentService(paymentAppCredential); + const paymentData = await paymentInstance.refund(successPayment.id); + if (!paymentData.refunded) { + throw new Error("Payment could not be refunded"); + } + } + } + // end handle refunds. + + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + status: BookingStatus.REJECTED, + rejectionReason, + }, + }); + } + + await sendDeclinedEmails(evt); + } + + const message = "Booking " + confirmed ? "confirmed" : "rejected"; + const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED; + + return { message, status }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts b/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts new file mode 100644 index 0000000000..dcd8149427 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/confirm.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils"; + +export const ZConfirmInputSchema = bookingConfirmPatchBodySchema; + +export type TConfirmInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts new file mode 100644 index 0000000000..098335d87b --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -0,0 +1,116 @@ +import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; +import { sendLocationChangeEmails } from "@calcom/emails"; +import { parseRecurringEvent } from "@calcom/lib"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TEditLocationInputSchema } from "./editLocation.schema"; +import type { BookingsProcedureContext } from "./util"; + +type EditLocationOptions = { + ctx: { + user: NonNullable; + } & BookingsProcedureContext; + input: TEditLocationInputSchema; +}; + +export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) => { + const { bookingId, newLocation: location } = input; + const { booking } = ctx; + + try { + const organizer = await prisma.user.findFirstOrThrow({ + where: { + id: booking.userId || 0, + }, + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + }); + + const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); + + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + title: booking.title || "", + type: (booking.eventType?.title as string) || booking?.title || "", + description: booking.description || "", + startTime: booking.startTime ? dayjs(booking.startTime).format() : "", + endTime: booking.endTime ? dayjs(booking.endTime).format() : "", + organizer: { + email: organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + location, + destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar, + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, + }; + + const eventManager = new EventManager(ctx.user); + + const updatedResult = await eventManager.updateLocation(evt, booking); + const results = updatedResult.results; + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingUpdateLocationFailed", + message: "Updating location failed", + }; + logger.error(`Booking ${ctx.user.username} failed`, error, results); + } else { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + location, + references: { + create: updatedResult.referencesToCreate, + }, + }, + }); + + const metadata: AdditionalInformation = {}; + if (results.length) { + metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; + metadata.conferenceData = results[0].updatedEvent?.conferenceData; + metadata.entryPoints = results[0].updatedEvent?.entryPoints; + } + try { + await sendLocationChangeEmails({ ...evt, additionalInformation: metadata }); + } catch (error) { + console.log("Error sending LocationChangeEmails"); + } + } + } catch { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + return { message: "Location updated" }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts new file mode 100644 index 0000000000..bde1cd8c3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { DailyLocationType } from "@calcom/app-store/locations"; + +import { commonBookingSchema } from "./types"; + +export const ZEditLocationInputSchema = commonBookingSchema.extend({ + newLocation: z.string().transform((val) => val || DailyLocationType), +}); + +export type TEditLocationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts new file mode 100644 index 0000000000..a2424600f4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -0,0 +1,306 @@ +import { BookingStatus } from "@prisma/client"; + +import { parseRecurringEvent } from "@calcom/lib"; +import { bookingMinimalSelect } from "@calcom/prisma"; +import type { Prisma, PrismaClient } from "@calcom/prisma/client"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + // using offset actually because cursor pagination requires a unique column + // for orderBy, but we don't use a unique column in our orderBy + const take = input.limit ?? 10; + const skip = input.cursor ?? 0; + const { prisma, user } = ctx; + const bookingListingByStatus = input.filters.status; + const bookingListingFilters: Record = { + upcoming: { + endTime: { gte: new Date() }, + // These changes are needed to not show confirmed recurring events, + // as rescheduling or cancel for recurring event bookings should be + // handled separately for each occurrence + OR: [ + { + recurringEventId: { not: null }, + status: { equals: BookingStatus.ACCEPTED }, + }, + { + recurringEventId: { equals: null }, + status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] }, + }, + ], + }, + recurring: { + endTime: { gte: new Date() }, + AND: [ + { NOT: { recurringEventId: { equals: null } } }, + { status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } }, + ], + }, + past: { + endTime: { lte: new Date() }, + AND: [ + { NOT: { status: { equals: BookingStatus.CANCELLED } } }, + { NOT: { status: { equals: BookingStatus.REJECTED } } }, + ], + }, + cancelled: { + OR: [{ status: { equals: BookingStatus.CANCELLED } }, { status: { equals: BookingStatus.REJECTED } }], + }, + unconfirmed: { + endTime: { gte: new Date() }, + status: { equals: BookingStatus.PENDING }, + }, + }; + const bookingListingOrderby: Record< + typeof bookingListingByStatus, + Prisma.BookingOrderByWithAggregationInput + > = { + upcoming: { startTime: "asc" }, + recurring: { startTime: "asc" }, + past: { startTime: "desc" }, + cancelled: { startTime: "desc" }, + unconfirmed: { startTime: "asc" }, + }; + + // TODO: Fix record typing + const bookingWhereInputFilters: Record = { + teamIds: { + AND: [ + { + eventType: { + team: { + id: { + in: input.filters?.teamIds, + }, + }, + }, + }, + ], + }, + userIds: { + AND: [ + { + eventType: { + users: { + some: { + id: { + in: input.filters?.userIds, + }, + }, + }, + }, + }, + ], + }, + }; + + const filtersCombined: Prisma.BookingWhereInput[] = + input.filters && + Object.keys(input.filters).map((key) => { + return bookingWhereInputFilters[key]; + }); + + const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; + const orderBy = bookingListingOrderby[bookingListingByStatus]; + + const [bookingsQuery, recurringInfoBasic, recurringInfoExtended] = await Promise.all([ + prisma.booking.findMany({ + where: { + OR: [ + { + userId: user.id, + }, + { + attendees: { + some: { + email: user.email, + }, + }, + }, + { + eventType: { + team: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + { + seatsReferences: { + some: { + attendee: { + email: user.email, + }, + }, + }, + }, + ], + AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])], + }, + select: { + ...bookingMinimalSelect, + uid: true, + recurringEventId: true, + location: true, + eventType: { + select: { + slug: true, + id: true, + eventName: true, + price: true, + recurringEvent: true, + currency: true, + metadata: true, + team: { + select: { + name: true, + }, + }, + }, + }, + status: true, + paid: true, + payment: { + select: { + paymentOption: true, + amount: true, + currency: true, + success: true, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + rescheduled: true, + references: true, + isRecorded: true, + seatsReferences: { + where: { + attendee: { + email: user.email, + }, + }, + select: { + referenceUid: true, + attendee: { + select: { + email: true, + }, + }, + }, + }, + }, + orderBy, + take: take + 1, + skip, + }), + prisma.booking.groupBy({ + by: ["recurringEventId"], + _min: { + startTime: true, + }, + _count: { + recurringEventId: true, + }, + where: { + recurringEventId: { + not: { equals: null }, + }, + userId: user.id, + }, + }), + prisma.booking.groupBy({ + by: ["recurringEventId", "status", "startTime"], + _min: { + startTime: true, + }, + where: { + recurringEventId: { + not: { equals: null }, + }, + userId: user.id, + }, + }), + ]); + + const recurringInfo = recurringInfoBasic.map( + ( + info: (typeof recurringInfoBasic)[number] + ): { + recurringEventId: string | null; + count: number; + firstDate: Date | null; + bookings: { + [key: string]: Date[]; + }; + } => { + const bookings = recurringInfoExtended.reduce( + (prev, curr) => { + if (curr.recurringEventId === info.recurringEventId) { + prev[curr.status].push(curr.startTime); + } + return prev; + }, + { ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [] } as { + [key in BookingStatus]: Date[]; + } + ); + return { + recurringEventId: info.recurringEventId, + count: info._count.recurringEventId, + firstDate: info._min.startTime, + bookings, + }; + } + ); + + const bookings = bookingsQuery.map((booking) => { + return { + ...booking, + eventType: { + ...booking.eventType, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + price: booking.eventType?.price || 0, + currency: booking.eventType?.currency || "usd", + metadata: EventTypeMetaDataSchema.parse(booking.eventType?.metadata || {}), + }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + }; + }); + + const bookingsFetched = bookings.length; + let nextCursor: typeof skip | null = skip; + if (bookingsFetched > take) { + nextCursor += bookingsFetched; + } else { + nextCursor = null; + } + + return { + bookings, + recurringInfo, + nextCursor, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts new file mode 100644 index 0000000000..c06284ac02 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + filters: z.object({ + teamIds: z.number().array().optional(), + userIds: z.number().array().optional(), + status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), + eventTypeIds: z.number().array().optional(), + }), + limit: z.number().min(1).max(100).nullish(), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts new file mode 100644 index 0000000000..2f0589a6ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.handler.ts @@ -0,0 +1,33 @@ +import { prisma } from "@calcom/prisma"; + +import type { TGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; + +type GetBookingAttendeesOptions = { + ctx: Record; + input: TGetBookingAttendeesInputSchema; +}; + +export const getBookingAttendeesHandler = async ({ ctx: _ctx, input }: GetBookingAttendeesOptions) => { + const bookingSeat = await prisma.bookingSeat.findUniqueOrThrow({ + where: { + referenceUid: input.seatReferenceUid, + }, + select: { + booking: { + select: { + _count: { + select: { + seatsReferences: true, + }, + }, + }, + }, + }, + }); + + if (!bookingSeat) { + throw new Error("Booking not found"); + } + + return bookingSeat.booking._count.seatsReferences; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts new file mode 100644 index 0000000000..39087267b1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getBookingAttendees.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ZGetBookingAttendeesInputSchema = z.object({ seatReferenceUid: z.string().uuid() }); + +export type TGetBookingAttendeesInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts new file mode 100644 index 0000000000..75150486a4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -0,0 +1,254 @@ +import type { BookingReference, EventType, WebhookTriggerEvents } from "@prisma/client"; +import { BookingStatus, WorkflowMethods } from "@prisma/client"; +import type { TFunction } from "next-i18next"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; +import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; +import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; +import { deleteMeeting } from "@calcom/core/videoClient"; +import dayjs from "@calcom/dayjs"; +import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; +import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; +import { sendRequestRescheduleEmail } from "@calcom/emails"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { isPrismaObjOrUndefined } from "@calcom/lib"; +import { getTranslation } from "@calcom/lib/server"; +import { prisma } from "@calcom/prisma"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TRequestRescheduleInputSchema } from "./requestReschedule.schema"; +import type { PersonAttendeeCommonFields } from "./types"; + +type RequestRescheduleOptions = { + ctx: { + user: NonNullable; + }; + input: TRequestRescheduleInputSchema; +}; + +export const requestRescheduleHandler = async ({ ctx, input }: RequestRescheduleOptions) => { + const { user } = ctx; + const { bookingId, rescheduleReason: cancellationReason } = input; + + const bookingToReschedule = await prisma.booking.findFirstOrThrow({ + select: { + id: true, + uid: true, + userId: true, + title: true, + description: true, + startTime: true, + endTime: true, + eventTypeId: true, + eventType: true, + location: true, + attendees: true, + references: true, + customInputs: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + destinationCalendar: true, + smsReminderNumber: true, + scheduledJobs: true, + workflowReminders: true, + responses: true, + }, + where: { + uid: bookingId, + NOT: { + status: { + in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], + }, + }, + }, + }); + + if (!bookingToReschedule.userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Booking to reschedule doesn't have an owner" }); + } + + if (!bookingToReschedule.eventType) { + throw new TRPCError({ code: "FORBIDDEN", message: "EventType not found for current booking." }); + } + + const bookingBelongsToTeam = !!bookingToReschedule.eventType?.teamId; + + const userTeams = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + select: { + teams: true, + }, + }); + + if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) { + const userTeamIds = userTeams.teams.map((item) => item.teamId); + if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" }); + } + } + if (!bookingBelongsToTeam && bookingToReschedule.userId !== user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" }); + } + + if (bookingToReschedule) { + let event: Partial = {}; + if (bookingToReschedule.eventTypeId) { + event = await prisma.eventType.findFirstOrThrow({ + select: { + title: true, + users: true, + schedulingType: true, + recurringEvent: true, + }, + where: { + id: bookingToReschedule.eventTypeId, + }, + }); + } + await prisma.booking.update({ + where: { + id: bookingToReschedule.id, + }, + data: { + rescheduled: true, + cancellationReason, + status: BookingStatus.CANCELLED, + updatedAt: dayjs().toISOString(), + }, + }); + + // delete scheduled jobs of previous booking + cancelScheduledJobs(bookingToReschedule); + + //cancel workflow reminders of previous booking + bookingToReschedule.workflowReminders.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + const [mainAttendee] = bookingToReschedule.attendees; + // @NOTE: Should we assume attendees language? + const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); + const usersToPeopleType = ( + users: PersonAttendeeCommonFields[], + selectedLanguage: TFunction + ): Person[] => { + return users?.map((user) => { + return { + email: user.email || "", + name: user.name || "", + username: user?.username || "", + language: { translate: selectedLanguage, locale: user.locale || "en" }, + timeZone: user?.timeZone, + }; + }); + }; + + const userTranslation = await getTranslation(user.locale ?? "en", "common"); + const [userAsPeopleType] = usersToPeopleType([user], userTranslation); + + const builder = new CalendarEventBuilder(); + builder.init({ + title: bookingToReschedule.title, + type: event && event.title ? event.title : bookingToReschedule.title, + startTime: bookingToReschedule.startTime.toISOString(), + endTime: bookingToReschedule.endTime.toISOString(), + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + organizer: userAsPeopleType, + }); + + const director = new CalendarEventDirector(); + director.setBuilder(builder); + director.setExistingBooking(bookingToReschedule); + cancellationReason && director.setCancellationReason(cancellationReason); + if (event) { + await director.buildForRescheduleEmail(); + } else { + await director.buildWithoutEventTypeForRescheduleEmail(); + } + + // Handling calendar and videos cancellation + // This can set previous time as available, until virtual calendar is done + const credentialsMap = new Map(); + user.credentials.forEach((credential) => { + credentialsMap.set(credential.type, credential); + }); + const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) => + credentialsMap.has(ref.type) + ); + bookingRefsFiltered.forEach(async (bookingRef) => { + if (bookingRef.uid) { + if (bookingRef.type.endsWith("_calendar")) { + const calendar = await getCalendar(credentialsMap.get(bookingRef.type)); + + return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId); + } else if (bookingRef.type.endsWith("_video")) { + return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); + } + } + }); + + // Send emails + await sendRequestRescheduleEmail(builder.calendarEvent, { + rescheduleLink: builder.rescheduleLink, + }); + + const evt: CalendarEvent = { + title: bookingToReschedule?.title, + type: event && event.title ? event.title : bookingToReschedule.title, + description: bookingToReschedule?.description || "", + customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), + ...getCalEventResponses({ + booking: bookingToReschedule, + bookingFields: bookingToReschedule.eventType?.bookingFields ?? null, + }), + startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", + endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", + organizer: userAsPeopleType, + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + uid: bookingToReschedule?.uid, + location: bookingToReschedule?.location, + destinationCalendar: + bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, + cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this + }; + + // Send webhook + const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; + // Send Webhook call if hooked to BOOKING.CANCELLED + const subscriberOptions = { + userId: bookingToReschedule.userId, + eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, + triggerEvent: eventTrigger, + }; + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { + ...evt, + smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined, + }).catch((e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); + }) + ); + await Promise.all(promises); + } +}; diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts new file mode 100644 index 0000000000..32c513e321 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRequestRescheduleInputSchema = z.object({ + bookingId: z.string(), + rescheduleReason: z.string().optional(), +}); + +export type TRequestRescheduleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/types.ts b/packages/trpc/server/routers/viewer/bookings/types.ts new file mode 100644 index 0000000000..1cca8e0ef0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/types.ts @@ -0,0 +1,12 @@ +import type { User } from "@prisma/client"; +import { z } from "zod"; + +export type PersonAttendeeCommonFields = Pick< + User, + "id" | "email" | "name" | "locale" | "timeZone" | "username" +>; + +// Common data for all endpoints under webhook +export const commonBookingSchema = z.object({ + bookingId: z.number(), +}); diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts new file mode 100644 index 0000000000..cd1e178c79 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -0,0 +1,80 @@ +import type { + Attendee, + Booking, + BookingReference, + Credential, + DestinationCalendar, + EventType, + User, +} from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import { commonBookingSchema } from "./types"; + +export const bookingsProcedure = authedProcedure + .input(commonBookingSchema) + .use(async ({ ctx, input, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + const { bookingId } = input; + + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + AND: [ + { + OR: [ + /* If user is organizer */ + { userId: ctx.user.id }, + /* Or part of a collective booking */ + { + eventType: { + schedulingType: SchedulingType.COLLECTIVE, + users: { + some: { + id: ctx.user.id, + }, + }, + }, + }, + ], + }, + ], + }, + include: { + attendees: true, + eventType: true, + destinationCalendar: true, + references: true, + user: { + include: { + destinationCalendar: true, + credentials: true, + }, + }, + }, + }); + + if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" }); + + return next({ ctx: { booking } }); + }); + +export type BookingsProcedureContext = { + booking: Booking & { + eventType: EventType | null; + destinationCalendar: DestinationCalendar | null; + user: + | (User & { + destinationCalendar: DestinationCalendar | null; + credentials: Credential[]; + }) + | null; + references: BookingReference[]; + attendees: Attendee[]; + }; +}; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup.tsx b/packages/trpc/server/routers/viewer/deploymentSetup.tsx deleted file mode 100644 index 8b1898cfb6..0000000000 --- a/packages/trpc/server/routers/viewer/deploymentSetup.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import prisma from "@calcom/prisma"; - -import { router, authedAdminProcedure } from "../../trpc"; - -export const deploymentSetupRouter = router({ - update: authedAdminProcedure - .input( - z.object({ - licenseKey: z.string().optional(), - }) - ) - .mutation(async ({ input }) => { - const data = { - agreedLicenseAt: new Date(), - licenseKey: input.licenseKey, - }; - - await prisma.deployment.upsert({ where: { id: 1 }, create: data, update: data }); - - return; - }), -}); diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx new file mode 100644 index 0000000000..c154c1b8f4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/_router.tsx @@ -0,0 +1,26 @@ +import { router, authedAdminProcedure } from "../../../trpc"; +import { ZUpdateInputSchema } from "./update.schema"; + +type DeploymentSetupRouterHandlerCache = { + update?: typeof import("./update.handler").updateHandler; +}; + +const UNSTABLE_HANDLER_CACHE: DeploymentSetupRouterHandlerCache = {}; + +export const deploymentSetupRouter = router({ + update: authedAdminProcedure.input(ZUpdateInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts new file mode 100644 index 0000000000..12b19e13ad --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.handler.ts @@ -0,0 +1,19 @@ +import { prisma } from "@calcom/prisma"; + +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: Record; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ input }: UpdateOptions) => { + const data = { + agreedLicenseAt: new Date(), + licenseKey: input.licenseKey, + }; + + await prisma.deployment.upsert({ where: { id: 1 }, create: data, update: data }); + + return; +}; diff --git a/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts new file mode 100644 index 0000000000..f2f499267d --- /dev/null +++ b/packages/trpc/server/routers/viewer/deploymentSetup/update.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + licenseKey: z.string().optional(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes.ts b/packages/trpc/server/routers/viewer/eventTypes.ts deleted file mode 100644 index 6efdf2132e..0000000000 --- a/packages/trpc/server/routers/viewer/eventTypes.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { MembershipRole, PeriodType, Prisma, SchedulingType } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; -import { orderBy } from "lodash"; -import type { NextApiResponse } from "next"; -import { z } from "zod"; - -import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; -import type { LocationObject } from "@calcom/app-store/locations"; -import { DailyLocationType } from "@calcom/app-store/locations"; -import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils"; -import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; -import { validateIntervalLimitOrder } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; -import getEventTypeById from "@calcom/lib/getEventTypeById"; -import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; -import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; -import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; -import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils"; -import { - customInputSchema, - EventTypeMetaDataSchema, - userMetadata as userMetadataSchema, -} from "@calcom/prisma/zod-utils"; -import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; -import { viewerRouter } from "../viewer"; - -function isPeriodType(keyInput: string): keyInput is PeriodType { - return Object.keys(PeriodType).includes(keyInput); -} - -function handlePeriodType(periodType: string | undefined): PeriodType | undefined { - if (typeof periodType !== "string") return undefined; - const passedPeriodType = periodType.toUpperCase(); - if (!isPeriodType(passedPeriodType)) return undefined; - return PeriodType[passedPeriodType]; -} - -function handleCustomInputs(customInputs: CustomInputSchema[], eventTypeId: number) { - const cInputsIdsToDeleteOrUpdated = customInputs.filter((input) => !input.hasToBeCreated); - const cInputsIdsToDelete = cInputsIdsToDeleteOrUpdated.map((e) => e.id); - const cInputsToCreate = customInputs - .filter((input) => input.hasToBeCreated) - .map((input) => ({ - type: input.type, - label: input.label, - required: input.required, - placeholder: input.placeholder, - options: input.options || undefined, - })); - const cInputsToUpdate = cInputsIdsToDeleteOrUpdated.map((input) => ({ - data: { - type: input.type, - label: input.label, - required: input.required, - placeholder: input.placeholder, - options: input.options || undefined, - }, - where: { - id: input.id, - }, - })); - - return { - deleteMany: { - eventTypeId, - NOT: { - id: { in: cInputsIdsToDelete }, - }, - }, - createMany: { - data: cInputsToCreate, - }, - update: cInputsToUpdate, - }; -} - -const EventTypeUpdateInput = _EventTypeModel - /** Optional fields */ - .extend({ - customInputs: z.array(customInputSchema).optional(), - destinationCalendar: _DestinationCalendarModel.pick({ - integration: true, - externalId: true, - }), - children: z - .array( - z.object({ - owner: z.object({ - id: z.number(), - name: z.string(), - email: z.string(), - eventTypeSlugs: z.array(z.string()), - }), - hidden: z.boolean(), - }) - ) - .optional(), - hosts: z - .array( - z.object({ - userId: z.number(), - isFixed: z.boolean().optional(), - }) - ) - .optional(), - schedule: z.number().nullable().optional(), - hashedLink: z.string(), - }) - .partial() - .extend({ - metadata: EventTypeMetaDataSchema.optional(), - }) - .merge( - _EventTypeModel - /** Required fields */ - .pick({ - id: true, - }) - ); - -const EventTypeDuplicateInput = z.object({ - id: z.number(), - slug: z.string(), - title: z.string(), - description: z.string(), - length: z.number(), -}); - -const eventOwnerProcedure = authedProcedure - .input( - z.object({ - id: z.number(), - users: z.array(z.number()).optional().default([]), - }) - ) - .use(async ({ ctx, input, next }) => { - // Prevent non-owners to update/delete a team event - const event = await ctx.prisma.eventType.findUnique({ - where: { id: input.id }, - include: { - users: true, - team: { - select: { - members: { - select: { - userId: true, - role: true, - }, - }, - }, - }, - }, - }); - - if (!event) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const isAuthorized = (function () { - if (event.team) { - return event.team.members - .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN) - .map((member) => member.userId) - .includes(ctx.user.id); - } - return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id); - })(); - - if (!isAuthorized) { - console.warn(`User ${ctx.user.id} attempted to an access an event ${event.id} they do not own.`); - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const isAllowed = (function () { - if (event.team) { - const allTeamMembers = event.team.members.map((member) => member.userId); - return input.users.every((userId: number) => allTeamMembers.includes(userId)); - } - return input.users.every((userId: number) => userId === ctx.user.id); - })(); - - if (!isAllowed) { - console.warn( - `User ${ctx.user.id} attempted to an create an event for users ${input.users.join(", ")}.` - ); - throw new TRPCError({ code: "FORBIDDEN" }); - } - - return next(); - }); - -export const eventTypesRouter = router({ - // REVIEW: What should we name this procedure? - getByViewer: authedProcedure.query(async ({ ctx }) => { - const { prisma } = ctx; - const eventTypeSelect = Prisma.validator()({ - // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering - position: true, - hashedLink: true, - locations: true, - destinationCalendar: true, - userId: true, - team: { - select: { - id: true, - name: true, - slug: true, - // logo: true, // Skipping to avoid 4mb limit - bio: true, - hideBranding: true, - }, - }, - metadata: true, - users: { - select: baseUserSelect, - }, - children: { - include: { - users: true, - }, - }, - hosts: { - select: { - user: { - select: baseUserSelect, - }, - }, - }, - seatsPerTimeSlot: true, - ...baseEventTypeSelect, - }); - - const user = await prisma.user.findUnique({ - where: { - id: ctx.user.id, - }, - select: { - id: true, - username: true, - name: true, - startTime: true, - endTime: true, - bufferTime: true, - avatar: true, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - members: { - select: { - userId: true, - }, - }, - eventTypes: { - select: eventTypeSelect, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }, - }, - }, - }, - }, - eventTypes: { - where: { - team: null, - }, - select: eventTypeSelect, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }, - }, - }); - - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - - const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ - ...eventType, - safeDescription: markdownToSafeHTML(eventType.description), - users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, - metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, - }); - - const userEventTypes = user.eventTypes.map(mapEventType); - // backwards compatibility, TMP: - const typesRaw = ( - await prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - }, - select: eventTypeSelect, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }) - ).map(mapEventType); - - type EventTypeGroup = { - teamId?: number | null; - membershipRole?: MembershipRole | null; - profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; - image?: string; - }; - metadata: { - membershipCount: number; - readOnly: boolean; - }; - eventTypes: typeof userEventTypes; - }; - - let eventTypeGroups: EventTypeGroup[] = []; - const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => { - const oldItem = hashMap[newItem.id]; - hashMap[newItem.id] = { ...oldItem, ...newItem }; - return hashMap; - }, {} as Record); - const mergedEventTypes = Object.values(eventTypesHashMap) - .map((eventType) => eventType) - .filter((evType) => evType.schedulingType !== SchedulingType.MANAGED); - eventTypeGroups.push({ - teamId: null, - membershipRole: null, - profile: { - slug: user.username, - name: user.name, - image: user.avatar || undefined, - }, - eventTypes: orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]), - metadata: { - membershipCount: 1, - readOnly: false, - }, - }); - - eventTypeGroups = ([] as EventTypeGroup[]).concat( - eventTypeGroups, - user.teams.map((membership) => ({ - teamId: membership.team.id, - membershipRole: membership.role, - profile: { - name: membership.team.name, - image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, - slug: membership.team.slug ? "team/" + membership.team.slug : null, - }, - metadata: { - membershipCount: membership.team.members.length, - readOnly: membership.role === MembershipRole.MEMBER, - }, - eventTypes: membership.team.eventTypes - .map(mapEventType) - .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) - .filter((evType) => - membership.role === MembershipRole.MEMBER - ? evType.schedulingType !== SchedulingType.MANAGED - : true - ), - })) - ); - return { - // don't display event teams without event types, - eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length), - // so we can show a dropdown when the user has teams - profiles: eventTypeGroups.map((group) => ({ - teamId: group.teamId, - membershipRole: group.membershipRole, - ...group.profile, - ...group.metadata, - })), - }; - }), - list: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - team: null, - }, - select: { - id: true, - title: true, - description: true, - length: true, - schedulingType: true, - slug: true, - hidden: true, - metadata: true, - }, - }); - }), - listWithTeam: authedProcedure.query(async ({ ctx }) => { - return await ctx.prisma.eventType.findMany({ - where: { - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - }, - }, - }, - }, - ], - }, - select: { - id: true, - team: { - select: { - id: true, - name: true, - }, - }, - title: true, - slug: true, - }, - }); - }), - create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => { - const { schedulingType, teamId, metadata, ...rest } = input; - const userId = ctx.user.id; - const isManagedEventType = schedulingType === SchedulingType.MANAGED; - // Get Users default conferncing app - - const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; - const appKeys = await getAppKeysFromSlug("daily-video"); - - let locations: { type: string; link?: string }[] = []; - - // If no locations are passed in and the user has a daily api key then default to daily - if ( - (typeof rest?.locations === "undefined" || rest.locations?.length === 0) && - typeof appKeys.api_key === "string" - ) { - locations = [{ type: DailyLocationType }]; - } - - // If its defaulting to daily no point handling compute as its done - if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { - const credentials = ctx.user.credentials; - const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ; - const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found - locations = [{ type: locationType, link: defaultConferencingData.appLink }]; - } - - const data: Prisma.EventTypeCreateInput = { - ...rest, - owner: teamId ? undefined : { connect: { id: userId } }, - metadata: (metadata as Prisma.InputJsonObject) ?? undefined, - // Only connecting the current user for non-managed event type - users: isManagedEventType ? undefined : { connect: { id: userId } }, - locations, - }; - - if (teamId && schedulingType) { - const hasMembership = await ctx.prisma.membership.findFirst({ - where: { - userId, - teamId: teamId, - accepted: true, - }, - }); - - if (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) { - console.warn(`User ${userId} does not have permission to create this new event type`); - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - data.team = { - connect: { - id: teamId, - }, - }; - data.schedulingType = schedulingType; - } - - try { - const eventType = await ctx.prisma.eventType.create({ data }); - return { eventType }; - } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { - if (e.code === "P2002" && Array.isArray(e.meta?.target) && e.meta?.target.includes("slug")) { - throw new TRPCError({ code: "BAD_REQUEST", message: "URL Slug already exists for given user." }); - } - } - throw new TRPCError({ code: "BAD_REQUEST" }); - } - }), - get: eventOwnerProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - return await getEventTypeById({ - eventTypeId: input.id, - userId: ctx.user.id, - prisma: ctx.prisma, - isTrpcCall: true, - }); - }), - update: eventOwnerProcedure.input(EventTypeUpdateInput.strict()).mutation(async ({ ctx, input }) => { - const { - schedule, - periodType, - locations, - bookingLimits, - durationLimits, - destinationCalendar, - customInputs, - recurringEvent, - users, - children, - hosts, - id, - hashedLink, - // Extract this from the input so it doesn't get saved in the db - // eslint-disable-next-line - userId, - // eslint-disable-next-line - teamId, - bookingFields, - ...rest - } = input; - - ensureUniqueBookingFields(bookingFields); - - const data: Prisma.EventTypeUpdateInput = { - ...rest, - bookingFields, - metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), - }; - data.locations = locations ?? undefined; - if (periodType) { - data.periodType = handlePeriodType(periodType); - } - - if (recurringEvent) { - data.recurringEvent = { - dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject, - interval: recurringEvent.interval, - count: recurringEvent.count, - freq: recurringEvent.freq, - until: recurringEvent.until as unknown as Prisma.InputJsonObject, - tzid: recurringEvent.tzid, - }; - } else if (recurringEvent === null) { - data.recurringEvent = Prisma.DbNull; - } - - if (destinationCalendar) { - /** We connect or create a destination calendar to the event type instead of the user */ - await viewerRouter.createCaller(ctx).setDestinationCalendar({ - ...destinationCalendar, - eventTypeId: id, - }); - } - - if (customInputs) { - data.customInputs = handleCustomInputs(customInputs, id); - } - - if (bookingLimits) { - const isValid = validateIntervalLimitOrder(bookingLimits); - if (!isValid) - throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); - data.bookingLimits = bookingLimits; - } - - if (durationLimits) { - const isValid = validateIntervalLimitOrder(durationLimits); - if (!isValid) - throw new TRPCError({ code: "BAD_REQUEST", message: "Duration limits must be in ascending order." }); - data.durationLimits = durationLimits; - } - - if (schedule) { - // Check that the schedule belongs to the user - const userScheduleQuery = await ctx.prisma.schedule.findFirst({ - where: { - userId: ctx.user.id, - id: schedule, - }, - }); - if (userScheduleQuery) { - data.schedule = { - connect: { - id: schedule, - }, - }; - } - } - // allows unsetting a schedule through { schedule: null, ... } - else if (null === schedule) { - data.schedule = { - disconnect: true, - }; - } - - if (users.length) { - data.users = { - set: [], - connect: users.map((userId: number) => ({ id: userId })), - }; - } - - if (hosts) { - data.hosts = { - deleteMany: {}, - create: hosts.map((host) => ({ - ...host, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - })), - }; - } - - const connectedLink = await ctx.prisma.hashedLink.findFirst({ - where: { - eventTypeId: input.id, - }, - select: { - id: true, - }, - }); - - if (hashedLink) { - // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection - if (!connectedLink) { - // create a hashed link - await ctx.prisma.hashedLink.upsert({ - where: { - eventTypeId: input.id, - }, - update: { - link: hashedLink, - }, - create: { - link: hashedLink, - eventType: { - connect: { id: input.id }, - }, - }, - }); - } - } else { - // check if hashed connection exists. If it does, disconnect - if (connectedLink) { - await ctx.prisma.hashedLink.delete({ - where: { - eventTypeId: input.id, - }, - }); - } - } - const [oldEventType, eventType] = await ctx.prisma.$transaction([ - ctx.prisma.eventType.findFirst({ - where: { id }, - select: { - children: { - select: { - userId: true, - }, - }, - team: { - select: { - name: true, - }, - }, - }, - }), - ctx.prisma.eventType.update({ - where: { id }, - data, - }), - ]); - - // Handling updates to children event types (managed events types) - await updateChildrenEventTypes({ - eventTypeId: id, - currentUserId: ctx.user.id, - oldEventType, - hashedLink, - connectedLink, - updatedEventType: eventType, - children, - prisma: ctx.prisma, - }); - const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - await res?.revalidate(`/${ctx.user.username}/${eventType.slug}`); - } - return { eventType }; - }), - delete: eventOwnerProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - await ctx.prisma.eventTypeCustomInput.deleteMany({ - where: { - eventTypeId: id, - }, - }); - - await ctx.prisma.eventType.delete({ - where: { - id, - }, - }); - - return { - id, - }; - }), - duplicate: eventOwnerProcedure.input(EventTypeDuplicateInput.strict()).mutation(async ({ ctx, input }) => { - try { - const { - id: originalEventTypeId, - title: newEventTitle, - slug: newSlug, - description: newDescription, - length: newLength, - } = input; - const eventType = await ctx.prisma.eventType.findUnique({ - where: { - id: originalEventTypeId, - }, - include: { - customInputs: true, - schedule: true, - users: true, - team: true, - workflows: true, - webhooks: true, - }, - }); - - if (!eventType) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - // Validate user is owner of event type or in the team - if (eventType.userId !== ctx.user.id) { - if (eventType.teamId) { - const isMember = await ctx.prisma.membership.findFirst({ - where: { - userId: ctx.user.id, - teamId: eventType.teamId, - }, - }); - if (!isMember) { - throw new TRPCError({ code: "FORBIDDEN" }); - } - } - } - - const { - customInputs, - users, - locations, - team, - recurringEvent, - bookingLimits, - durationLimits, - metadata, - workflows, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - id: _id, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - webhooks: _webhooks, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - schedule: _schedule, - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-ts-comment - // @ts-ignore - descriptionAsSafeHTML is added on the fly using a prisma middleware it shouldn't be used to create event type. Such a property doesn't exist on schema - descriptionAsSafeHTML: _descriptionAsSafeHTML, - ...rest - } = eventType; - - const data: Prisma.EventTypeUncheckedCreateInput = { - ...rest, - title: newEventTitle, - slug: newSlug, - description: newDescription, - length: newLength, - locations: locations ?? undefined, - teamId: team ? team.id : undefined, - users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined, - recurringEvent: recurringEvent || undefined, - bookingLimits: bookingLimits ?? undefined, - durationLimits: durationLimits ?? undefined, - metadata: metadata === null ? Prisma.DbNull : metadata, - bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, - }; - - const newEventType = await ctx.prisma.eventType.create({ data }); - - // Create custom inputs - if (customInputs) { - const customInputsData = customInputs.map((customInput) => { - const { id: _, options, ...rest } = customInput; - return { - options: options ?? undefined, - ...rest, - eventTypeId: newEventType.id, - }; - }); - await ctx.prisma.eventTypeCustomInput.createMany({ - data: customInputsData, - }); - } - - if (workflows.length > 0) { - const relationCreateData = workflows.map((workflow) => { - return { eventTypeId: newEventType.id, workflowId: workflow.workflowId }; - }); - - await ctx.prisma.workflowsOnEventTypes.createMany({ - data: relationCreateData, - }); - } - - return { - eventType: newEventType, - }; - } catch (error) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - }), - bulkEventFetch: authedProcedure.query(async ({ ctx }) => { - const eventTypes = await ctx.prisma.eventType.findMany({ - where: { - userId: ctx.user.id, - team: null, - }, - select: { - id: true, - title: true, - locations: true, - }, - }); - - const eventTypesWithLogo = eventTypes.map((eventType) => { - const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations); - - // some events has null as location for legacy reasons, so this fallbacks to daily video - const app = getAppFromLocationValue( - locationParsed.success && locationParsed.data?.[0]?.type - ? locationParsed.data[0].type - : "integrations:daily" - ); - return { - ...eventType, - logo: app?.logo, - }; - }); - - return { - eventTypes: eventTypesWithLogo, - }; - }), - - bulkUpdateToDefaultLocation: authedProcedure - .input( - z.object({ - eventTypeIds: z.array(z.number()), - }) - ) - .mutation(async ({ ctx, input }) => { - const { eventTypeIds } = input; - const defaultApp = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; - - if (!defaultApp) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Default conferencing app not set", - }); - } - - const foundApp = getAppFromSlug(defaultApp.appSlug); - const appType = foundApp?.appData?.location?.type; - if (!appType) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`, - }); - } - - return await ctx.prisma.eventType.updateMany({ - where: { - id: { - in: eventTypeIds, - }, - userId: ctx.user.id, - }, - data: { - locations: [{ type: appType, link: defaultApp.appLink }] as LocationObject[], - }, - }); - }), -}); - -function ensureUniqueBookingFields(fields: z.infer["bookingFields"]) { - if (!fields) { - return; - } - fields.reduce((discoveredFields, field) => { - if (discoveredFields[field.name]) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Duplicate booking field name: ${field.name}`, - }); - } - discoveredFields[field.name] = true; - return discoveredFields; - }, {} as Record); -} diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts new file mode 100644 index 0000000000..37fcebc627 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; + +import { logP } from "@calcom/lib/perf"; + +import { authedProcedure, router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZDuplicateInputSchema } from "./duplicate.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { eventOwnerProcedure } from "./util"; + +type BookingsRouterHandlerCache = { + getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler; + list?: typeof import("./list.handler").listHandler; + listWithTeam?: typeof import("./listWithTeam.handler").listWithTeamHandler; + create?: typeof import("./create.handler").createHandler; + get?: typeof import("./get.handler").getHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + duplicate?: typeof import("./duplicate.handler").duplicateHandler; + bulkEventFetch?: typeof import("./bulkEventFetch.handler").bulkEventFetchHandler; + bulkUpdateToDefaultLocation?: typeof import("./bulkUpdateToDefaultLocation.handler").bulkUpdateToDefaultLocationHandler; +}; + +const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; + +export const eventTypesRouter = router({ + // REVIEW: What should we name this procedure? + getByViewer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then( + (mod) => mod.getByViewerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + throw new Error("Failed to load handler"); + } + + const timer = logP(`getByViewer(${ctx.user.email})`); + + const result = await UNSTABLE_HANDLER_CACHE.getByViewer({ + ctx, + }); + + timer(); + + return result; + }), + + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + listWithTeam: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.listWithTeam) { + UNSTABLE_HANDLER_CACHE.listWithTeam = await import("./listWithTeam.handler").then( + (mod) => mod.listWithTeamHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listWithTeam) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listWithTeam({ + ctx, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + get: eventOwnerProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + update: eventOwnerProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + delete: eventOwnerProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + duplicate: eventOwnerProcedure.input(ZDuplicateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.duplicate) { + UNSTABLE_HANDLER_CACHE.duplicate = await import("./duplicate.handler").then( + (mod) => mod.duplicateHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.duplicate) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.duplicate({ + ctx, + input, + }); + }), + + bulkEventFetch: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.bulkEventFetch) { + UNSTABLE_HANDLER_CACHE.bulkEventFetch = await import("./bulkEventFetch.handler").then( + (mod) => mod.bulkEventFetchHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bulkEventFetch) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bulkEventFetch({ + ctx, + }); + }), + + bulkUpdateToDefaultLocation: authedProcedure + .input( + z.object({ + eventTypeIds: z.array(z.number()), + }) + ) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation) { + UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation = await import( + "./bulkUpdateToDefaultLocation.handler" + ).then((mod) => mod.bulkUpdateToDefaultLocationHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.bulkUpdateToDefaultLocation({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts new file mode 100644 index 0000000000..107581f947 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.handler.ts @@ -0,0 +1,44 @@ +import { getAppFromLocationValue } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type BulkEventFetchOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const bulkEventFetchHandler = async ({ ctx }: BulkEventFetchOptions) => { + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + team: null, + }, + select: { + id: true, + title: true, + locations: true, + }, + }); + + const eventTypesWithLogo = eventTypes.map((eventType) => { + const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations); + + // some events has null as location for legacy reasons, so this fallbacks to daily video + const app = getAppFromLocationValue( + locationParsed.success && locationParsed.data?.[0]?.type + ? locationParsed.data[0].type + : "integrations:daily" + ); + return { + ...eventType, + logo: app?.logo, + }; + }); + + return { + eventTypes: eventTypesWithLogo, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkEventFetch.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts new file mode 100644 index 0000000000..a153720721 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts @@ -0,0 +1,52 @@ +import type { LocationObject } from "@calcom/app-store/locations"; +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TBulkUpdateToDefaultLocationInputSchema } from "./bulkUpdateToDefaultLocation.schema"; + +type BulkUpdateToDefaultLocationOptions = { + ctx: { + user: NonNullable; + }; + input: TBulkUpdateToDefaultLocationInputSchema; +}; + +export const bulkUpdateToDefaultLocationHandler = async ({ + ctx, + input, +}: BulkUpdateToDefaultLocationOptions) => { + const { eventTypeIds } = input; + const defaultApp = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; + + if (!defaultApp) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Default conferencing app not set", + }); + } + + const foundApp = getAppFromSlug(defaultApp.appSlug); + const appType = foundApp?.appData?.location?.type; + if (!appType) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`, + }); + } + + return await prisma.eventType.updateMany({ + where: { + id: { + in: eventTypeIds, + }, + userId: ctx.user.id, + }, + data: { + locations: [{ type: appType, link: defaultApp.appLink }] as LocationObject[], + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts new file mode 100644 index 0000000000..4cd2d27eba --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZBulkUpdateToDefaultLocationInputSchema = z.object({ + eventTypeIds: z.array(z.number()), +}); + +export type TBulkUpdateToDefaultLocationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts new file mode 100644 index 0000000000..10fd91f632 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -0,0 +1,93 @@ +import type { Prisma } from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { DailyLocationType } from "@calcom/app-store/locations"; +import getApps from "@calcom/app-store/utils"; +import type { PrismaClient } from "@calcom/prisma/client"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { schedulingType, teamId, metadata, ...rest } = input; + const userId = ctx.user.id; + const isManagedEventType = schedulingType === SchedulingType.MANAGED; + // Get Users default conferncing app + + const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; + const appKeys = await getAppKeysFromSlug("daily-video"); + + let locations: { type: string; link?: string }[] = []; + + // If no locations are passed in and the user has a daily api key then default to daily + if ( + (typeof rest?.locations === "undefined" || rest.locations?.length === 0) && + typeof appKeys.api_key === "string" + ) { + locations = [{ type: DailyLocationType }]; + } + + // If its defaulting to daily no point handling compute as its done + if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { + const credentials = ctx.user.credentials; + const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ; + const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found + locations = [{ type: locationType, link: defaultConferencingData.appLink }]; + } + + const data: Prisma.EventTypeCreateInput = { + ...rest, + owner: teamId ? undefined : { connect: { id: userId } }, + metadata: (metadata as Prisma.InputJsonObject) ?? undefined, + // Only connecting the current user for non-managed event type + users: isManagedEventType ? undefined : { connect: { id: userId } }, + locations, + }; + + if (teamId && schedulingType) { + const hasMembership = await ctx.prisma.membership.findFirst({ + where: { + userId, + teamId: teamId, + accepted: true, + }, + }); + + if (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) { + console.warn(`User ${userId} does not have permission to create this new event type`); + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + data.team = { + connect: { + id: teamId, + }, + }; + data.schedulingType = schedulingType; + } + + try { + const eventType = await ctx.prisma.eventType.create({ data }); + return { eventType }; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + if (e.code === "P2002" && Array.isArray(e.meta?.target) && e.meta?.target.includes("slug")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "URL Slug already exists for given user." }); + } + } + throw new TRPCError({ code: "BAD_REQUEST" }); + } +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts new file mode 100644 index 0000000000..fc39339be8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/create.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype"; + +export const ZCreateInputSchema = createEventTypeInput; + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts new file mode 100644 index 0000000000..ea275e5443 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts @@ -0,0 +1,31 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx: _ctx, input }: DeleteOptions) => { + const { id } = input; + + await prisma.eventTypeCustomInput.deleteMany({ + where: { + eventTypeId: id, + }, + }); + + await prisma.eventType.delete({ + where: { + id, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts new file mode 100644 index 0000000000..411d5e953b --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts new file mode 100644 index 0000000000..67ddcc0fc6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts @@ -0,0 +1,130 @@ +import { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDuplicateInputSchema } from "./duplicate.schema"; + +type DuplicateOptions = { + ctx: { + user: NonNullable; + }; + input: TDuplicateInputSchema; +}; + +export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { + try { + const { + id: originalEventTypeId, + title: newEventTitle, + slug: newSlug, + description: newDescription, + length: newLength, + } = input; + const eventType = await prisma.eventType.findUnique({ + where: { + id: originalEventTypeId, + }, + include: { + customInputs: true, + schedule: true, + users: true, + team: true, + workflows: true, + webhooks: true, + }, + }); + + if (!eventType) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + // Validate user is owner of event type or in the team + if (eventType.userId !== ctx.user.id) { + if (eventType.teamId) { + const isMember = await prisma.membership.findFirst({ + where: { + userId: ctx.user.id, + teamId: eventType.teamId, + }, + }); + if (!isMember) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } + } + + const { + customInputs, + users, + locations, + team, + recurringEvent, + bookingLimits, + durationLimits, + metadata, + workflows, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + id: _id, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + webhooks: _webhooks, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + schedule: _schedule, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-ts-comment + // @ts-ignore - descriptionAsSafeHTML is added on the fly using a prisma middleware it shouldn't be used to create event type. Such a property doesn't exist on schema + descriptionAsSafeHTML: _descriptionAsSafeHTML, + ...rest + } = eventType; + + const data: Prisma.EventTypeUncheckedCreateInput = { + ...rest, + title: newEventTitle, + slug: newSlug, + description: newDescription, + length: newLength, + locations: locations ?? undefined, + teamId: team ? team.id : undefined, + users: users ? { connect: users.map((user) => ({ id: user.id })) } : undefined, + recurringEvent: recurringEvent || undefined, + bookingLimits: bookingLimits ?? undefined, + durationLimits: durationLimits ?? undefined, + metadata: metadata === null ? Prisma.DbNull : metadata, + bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, + }; + + const newEventType = await prisma.eventType.create({ data }); + + // Create custom inputs + if (customInputs) { + const customInputsData = customInputs.map((customInput) => { + const { id: _, options, ...rest } = customInput; + return { + options: options ?? undefined, + ...rest, + eventTypeId: newEventType.id, + }; + }); + await prisma.eventTypeCustomInput.createMany({ + data: customInputsData, + }); + } + + if (workflows.length > 0) { + const relationCreateData = workflows.map((workflow) => { + return { eventTypeId: newEventType.id, workflowId: workflow.workflowId }; + }); + + await prisma.workflowsOnEventTypes.createMany({ + data: relationCreateData, + }); + } + + return { + eventType: newEventType, + }; + } catch (error) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts new file mode 100644 index 0000000000..6b6944f1e6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { EventTypeDuplicateInput } from "./types"; + +export const ZDuplicateInputSchema = EventTypeDuplicateInput.strict(); + +export type TDuplicateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts new file mode 100644 index 0000000000..73e22c9ec8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts @@ -0,0 +1,22 @@ +import getEventTypeById from "@calcom/lib/getEventTypeById"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; +import type { PrismaClient } from ".prisma/client"; + +type GetOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetInputSchema; +}; + +export const getHandler = ({ ctx, input }: GetOptions) => { + return getEventTypeById({ + eventTypeId: input.id, + userId: ctx.user.id, + prisma: ctx.prisma, + isTrpcCall: true, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts new file mode 100644 index 0000000000..d549577697 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + id: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts new file mode 100644 index 0000000000..cc3133b397 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -0,0 +1,222 @@ +import { MembershipRole, Prisma, SchedulingType } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; +import { orderBy } from "lodash"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type GetByViewerOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; +}; + +export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { + const { prisma } = ctx; + const eventTypeSelect = Prisma.validator()({ + // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering + position: true, + hashedLink: true, + locations: true, + destinationCalendar: true, + userId: true, + team: { + select: { + id: true, + name: true, + slug: true, + // logo: true, // Skipping to avoid 4mb limit + bio: true, + hideBranding: true, + }, + }, + metadata: true, + users: { + select: baseUserSelect, + }, + children: { + include: { + users: true, + }, + }, + hosts: { + select: { + user: { + select: baseUserSelect, + }, + }, + }, + seatsPerTimeSlot: true, + ...baseEventTypeSelect, + }); + + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + avatar: true, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + members: { + select: { + userId: true, + }, + }, + eventTypes: { + select: eventTypeSelect, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }, + }, + }, + }, + }, + eventTypes: { + where: { + team: null, + }, + select: eventTypeSelect, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ + ...eventType, + safeDescription: markdownToSafeHTML(eventType.description), + users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, + metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, + }); + + const userEventTypes = user.eventTypes.map(mapEventType); + // backwards compatibility, TMP: + const typesRaw = ( + await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + }, + select: eventTypeSelect, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }) + ).map(mapEventType); + + type EventTypeGroup = { + teamId?: number | null; + membershipRole?: MembershipRole | null; + profile: { + slug: (typeof user)["username"]; + name: (typeof user)["name"]; + image?: string; + }; + metadata: { + membershipCount: number; + readOnly: boolean; + }; + eventTypes: typeof userEventTypes; + }; + + let eventTypeGroups: EventTypeGroup[] = []; + const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => { + const oldItem = hashMap[newItem.id]; + hashMap[newItem.id] = { ...oldItem, ...newItem }; + return hashMap; + }, {} as Record); + const mergedEventTypes = Object.values(eventTypesHashMap) + .map((eventType) => eventType) + .filter((evType) => evType.schedulingType !== SchedulingType.MANAGED); + eventTypeGroups.push({ + teamId: null, + membershipRole: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar || undefined, + }, + eventTypes: orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]), + metadata: { + membershipCount: 1, + readOnly: false, + }, + }); + + eventTypeGroups = ([] as EventTypeGroup[]).concat( + eventTypeGroups, + user.teams.map((membership) => ({ + teamId: membership.team.id, + membershipRole: membership.role, + profile: { + name: membership.team.name, + image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, + slug: membership.team.slug ? "team/" + membership.team.slug : null, + }, + metadata: { + membershipCount: membership.team.members.length, + readOnly: membership.role === MembershipRole.MEMBER, + }, + eventTypes: membership.team.eventTypes + .map(mapEventType) + .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) + .filter((evType) => + membership.role === MembershipRole.MEMBER ? evType.schedulingType !== SchedulingType.MANAGED : true + ), + })) + ); + return { + // don't display event teams without event types, + eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length), + // so we can show a dropdown when the user has teams + profiles: eventTypeGroups.map((group) => ({ + teamId: group.teamId, + membershipRole: group.membershipRole, + ...group.profile, + ...group.metadata, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts new file mode 100644 index 0000000000..f17398fdbe --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/list.handler.ts @@ -0,0 +1,28 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + return await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + team: null, + }, + select: { + id: true, + title: true, + description: true, + length: true, + schedulingType: true, + slug: true, + hidden: true, + metadata: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts new file mode 100644 index 0000000000..0897773bba --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts @@ -0,0 +1,39 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListWithTeamOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => { + return await prisma.eventType.findMany({ + where: { + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + }, + }, + }, + }, + ], + }, + select: { + id: true, + team: { + select: { + id: true, + name: true, + }, + }, + title: true, + slug: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts new file mode 100644 index 0000000000..65b34f009f --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; +import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils"; + +export const EventTypeUpdateInput = _EventTypeModel + /** Optional fields */ + .extend({ + customInputs: z.array(customInputSchema).optional(), + destinationCalendar: _DestinationCalendarModel.pick({ + integration: true, + externalId: true, + }), + users: z.array(stringOrNumber).optional(), + children: z + .array( + z.object({ + owner: z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + eventTypeSlugs: z.array(z.string()), + }), + hidden: z.boolean(), + }) + ) + .optional(), + hosts: z + .array( + z.object({ + userId: z.number(), + isFixed: z.boolean().optional(), + }) + ) + .optional(), + schedule: z.number().nullable().optional(), + hashedLink: z.string(), + }) + .partial() + .extend({ + metadata: EventTypeMetaDataSchema.optional(), + }) + .merge( + _EventTypeModel + /** Required fields */ + .pick({ + id: true, + }) + ); + +export const EventTypeDuplicateInput = z.object({ + id: z.number(), + slug: z.string(), + title: z.string(), + description: z.string(), + length: z.number(), +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts new file mode 100644 index 0000000000..a6c3067300 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -0,0 +1,240 @@ +import type { PrismaClient } from "@prisma/client"; +import { Prisma, SchedulingType } from "@prisma/client"; +import type { NextApiResponse, GetServerSidePropsContext } from "next"; + +import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server"; +import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; +import { validateIntervalLimitOrder } from "@calcom/lib"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import { setDestinationCalendarHandler } from "../../loggedInViewer/setDestinationCalendar.handler"; +import type { TUpdateInputSchema } from "./update.schema"; +import { ensureUniqueBookingFields, handleCustomInputs, handlePeriodType } from "./util"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + res?: NextApiResponse | GetServerSidePropsContext["res"]; + prisma: PrismaClient; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { + schedule, + periodType, + locations, + bookingLimits, + durationLimits, + destinationCalendar, + customInputs, + recurringEvent, + users, + children, + hosts, + id, + hashedLink, + // Extract this from the input so it doesn't get saved in the db + // eslint-disable-next-line + userId, + // eslint-disable-next-line + teamId, + bookingFields, + ...rest + } = input; + + ensureUniqueBookingFields(bookingFields); + + const data: Prisma.EventTypeUpdateInput = { + ...rest, + bookingFields, + metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), + }; + data.locations = locations ?? undefined; + if (periodType) { + data.periodType = handlePeriodType(periodType); + } + + if (recurringEvent) { + data.recurringEvent = { + dstart: recurringEvent.dtstart as unknown as Prisma.InputJsonObject, + interval: recurringEvent.interval, + count: recurringEvent.count, + freq: recurringEvent.freq, + until: recurringEvent.until as unknown as Prisma.InputJsonObject, + tzid: recurringEvent.tzid, + }; + } else if (recurringEvent === null) { + data.recurringEvent = Prisma.DbNull; + } + + if (destinationCalendar) { + /** We connect or create a destination calendar to the event type instead of the user */ + await setDestinationCalendarHandler({ + ctx, + input: { + ...destinationCalendar, + eventTypeId: id, + }, + }); + } + + if (customInputs) { + data.customInputs = handleCustomInputs(customInputs, id); + } + + if (bookingLimits) { + const isValid = validateIntervalLimitOrder(bookingLimits); + if (!isValid) + throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); + data.bookingLimits = bookingLimits; + } + + if (durationLimits) { + const isValid = validateIntervalLimitOrder(durationLimits); + if (!isValid) + throw new TRPCError({ code: "BAD_REQUEST", message: "Duration limits must be in ascending order." }); + data.durationLimits = durationLimits; + } + + if (schedule) { + // Check that the schedule belongs to the user + const userScheduleQuery = await ctx.prisma.schedule.findFirst({ + where: { + userId: ctx.user.id, + id: schedule, + }, + }); + if (userScheduleQuery) { + data.schedule = { + connect: { + id: schedule, + }, + }; + } + } + // allows unsetting a schedule through { schedule: null, ... } + else if (null === schedule) { + data.schedule = { + disconnect: true, + }; + } + + if (users?.length) { + data.users = { + set: [], + connect: users.map((userId: number) => ({ id: userId })), + }; + } + + if (hosts) { + data.hosts = { + deleteMany: {}, + create: hosts.map((host) => ({ + ...host, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + })), + }; + } + + if (input?.price || input.metadata?.apps?.stripe?.price) { + data.price = input.price || input.metadata?.apps?.stripe?.price; + const paymentCredential = await ctx.prisma.credential.findFirst({ + where: { + userId: ctx.user.id, + type: { + contains: "_payment", + }, + }, + select: { + type: true, + key: true, + }, + }); + + if (paymentCredential?.type === "stripe_payment") { + const { default_currency } = stripeDataSchema.parse(paymentCredential.key); + data.currency = default_currency; + } + } + + const connectedLink = await ctx.prisma.hashedLink.findFirst({ + where: { + eventTypeId: input.id, + }, + select: { + id: true, + }, + }); + + if (hashedLink) { + // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection + if (!connectedLink) { + // create a hashed link + await ctx.prisma.hashedLink.upsert({ + where: { + eventTypeId: input.id, + }, + update: { + link: hashedLink, + }, + create: { + link: hashedLink, + eventType: { + connect: { id: input.id }, + }, + }, + }); + } + } else { + // check if hashed connection exists. If it does, disconnect + if (connectedLink) { + await ctx.prisma.hashedLink.delete({ + where: { + eventTypeId: input.id, + }, + }); + } + } + const [oldEventType, eventType] = await ctx.prisma.$transaction([ + ctx.prisma.eventType.findFirst({ + where: { id }, + select: { + children: { + select: { + userId: true, + }, + }, + team: { + select: { + name: true, + }, + }, + }, + }), + ctx.prisma.eventType.update({ + where: { id }, + data, + }), + ]); + + // Handling updates to children event types (managed events types) + await updateChildrenEventTypes({ + eventTypeId: id, + currentUserId: ctx.user.id, + oldEventType, + hashedLink, + connectedLink, + updatedEventType: eventType, + children, + prisma: ctx.prisma, + }); + const res = ctx.res as NextApiResponse; + if (typeof res?.revalidate !== "undefined") { + await res?.revalidate(`/${ctx.user.username}/${eventType.slug}`); + } + return { eventType }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts new file mode 100644 index 0000000000..4e69d8d4be --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/update.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { EventTypeUpdateInput } from "./types"; + +export const ZUpdateInputSchema = EventTypeUpdateInput.strict(); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts new file mode 100644 index 0000000000..8a4407b980 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -0,0 +1,141 @@ +import { MembershipRole, PeriodType } from "@prisma/client"; +import { z } from "zod"; + +import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import type { EventTypeUpdateInput } from "./types"; + +export const eventOwnerProcedure = authedProcedure + .input( + z.object({ + id: z.number(), + users: z.array(z.number()).optional().default([]), + }) + ) + .use(async ({ ctx, input, next }) => { + // Prevent non-owners to update/delete a team event + const event = await ctx.prisma.eventType.findUnique({ + where: { id: input.id }, + include: { + users: true, + team: { + select: { + members: { + select: { + userId: true, + role: true, + }, + }, + }, + }, + }, + }); + + if (!event) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + const isAuthorized = (function () { + if (event.team) { + return event.team.members + .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN) + .map((member) => member.userId) + .includes(ctx.user.id); + } + return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id); + })(); + + if (!isAuthorized) { + console.warn(`User ${ctx.user.id} attempted to an access an event ${event.id} they do not own.`); + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const isAllowed = (function () { + if (event.team) { + const allTeamMembers = event.team.members.map((member) => member.userId); + return input.users.every((userId: number) => allTeamMembers.includes(userId)); + } + return input.users.every((userId: number) => userId === ctx.user.id); + })(); + + if (!isAllowed) { + console.warn( + `User ${ctx.user.id} attempted to an create an event for users ${input.users.join(", ")}.` + ); + throw new TRPCError({ code: "FORBIDDEN" }); + } + + return next(); + }); + +export function isPeriodType(keyInput: string): keyInput is PeriodType { + return Object.keys(PeriodType).includes(keyInput); +} + +export function handlePeriodType(periodType: string | undefined): PeriodType | undefined { + if (typeof periodType !== "string") return undefined; + const passedPeriodType = periodType.toUpperCase(); + if (!isPeriodType(passedPeriodType)) return undefined; + return PeriodType[passedPeriodType]; +} + +export function handleCustomInputs(customInputs: CustomInputSchema[], eventTypeId: number) { + const cInputsIdsToDeleteOrUpdated = customInputs.filter((input) => !input.hasToBeCreated); + const cInputsIdsToDelete = cInputsIdsToDeleteOrUpdated.map((e) => e.id); + const cInputsToCreate = customInputs + .filter((input) => input.hasToBeCreated) + .map((input) => ({ + type: input.type, + label: input.label, + required: input.required, + placeholder: input.placeholder, + options: input.options || undefined, + })); + const cInputsToUpdate = cInputsIdsToDeleteOrUpdated.map((input) => ({ + data: { + type: input.type, + label: input.label, + required: input.required, + placeholder: input.placeholder, + options: input.options || undefined, + }, + where: { + id: input.id, + }, + })); + + return { + deleteMany: { + eventTypeId, + NOT: { + id: { in: cInputsIdsToDelete }, + }, + }, + createMany: { + data: cInputsToCreate, + }, + update: cInputsToUpdate, + }; +} + +export function ensureUniqueBookingFields(fields: z.infer["bookingFields"]) { + if (!fields) { + return; + } + + fields.reduce((discoveredFields, field) => { + if (discoveredFields[field.name]) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Duplicate booking field name: ${field.name}`, + }); + } + + discoveredFields[field.name] = true; + + return discoveredFields; + }, {} as Record); +} diff --git a/packages/trpc/server/routers/viewer/payments/_router.ts b/packages/trpc/server/routers/viewer/payments/_router.ts new file mode 100644 index 0000000000..f78f105c4b --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/_router.ts @@ -0,0 +1,28 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZChargerCardInputSchema } from "./chargeCard.schema"; + +interface PaymentsRouterHandlerCache { + chargeCard?: typeof import("./chargeCard.handler").chargeCardHandler; +} + +const UNSTABLE_HANDLER_CACHE: PaymentsRouterHandlerCache = {}; + +export const paymentsRouter = router({ + chargeCard: authedProcedure.input(ZChargerCardInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.chargeCard) { + UNSTABLE_HANDLER_CACHE.chargeCard = await import("./chargeCard.handler").then( + (mod) => mod.chargeCardHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.chargeCard) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.chargeCard({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts new file mode 100644 index 0000000000..24f29f6f25 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts @@ -0,0 +1,121 @@ +import appStore from "@calcom/app-store"; +import dayjs from "@calcom/dayjs"; +import { sendNoShowFeeChargedEmail } from "@calcom/emails"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TChargeCardInputSchema } from "./chargeCard.schema"; + +interface ChargeCardHandlerOptions { + ctx: { user: NonNullable; prisma: PrismaClient }; + input: TChargeCardInputSchema; +} +export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions) => { + const { prisma } = ctx; + + const booking = await prisma.booking.findFirst({ + where: { + id: input.bookingId, + }, + include: { + payment: true, + user: true, + attendees: true, + eventType: true, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + if (booking.payment[0].success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `The no show fee for ${booking.id} has already been charged.`, + }); + } + + const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common"); + + const attendeesListPromises = []; + + for (const attendee of booking.attendees) { + const attendeeObject = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + + attendeesListPromises.push(attendeeObject); + } + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + type: (booking?.eventType?.title as string) || booking?.title, + title: booking.title, + startTime: dayjs(booking.startTime).format(), + endTime: dayjs(booking.endTime).format(), + organizer: { + email: booking.user?.email || "", + name: booking.user?.name || "Nameless", + timeZone: booking.user?.timeZone || "", + language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" }, + }, + attendees: attendeesList, + paymentInfo: { + amount: booking.payment[0].amount, + currency: booking.payment[0].currency, + paymentOption: booking.payment[0].paymentOption, + }, + }; + + const paymentCredential = await prisma.credential.findFirst({ + where: { + userId: ctx.user.id, + appId: booking.payment[0].appId, + }, + include: { + app: true, + }, + }); + + if (!paymentCredential?.app) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" }); + } + + const paymentApp = await appStore[paymentCredential?.app?.dirName as keyof typeof appStore]; + + if (!("lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" }); + } + + const PaymentService = paymentApp.lib.PaymentService; + const paymentInstance = new PaymentService(paymentCredential); + + try { + const paymentData = await paymentInstance.chargeCard(booking.payment[0]); + + if (!paymentData) { + throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` }); + } + + await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt); + + return paymentData; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error processing payment with error ${err}`, + }); + } +}; diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts new file mode 100644 index 0000000000..0f2cabaac1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { ChargerCardSchema } from "./type"; + +export const ZChargerCardInputSchema = ChargerCardSchema; + +export type TChargeCardInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/payments/type.ts b/packages/trpc/server/routers/viewer/payments/type.ts new file mode 100644 index 0000000000..356e019393 --- /dev/null +++ b/packages/trpc/server/routers/viewer/payments/type.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ChargerCardSchema = z.object({ + bookingId: z.number(), +}); + +export type TChargeCardSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx new file mode 100644 index 0000000000..bd1c9619dc --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { router, publicProcedure } from "../../../trpc"; +import { ZGetScheduleInputSchema } from "./getSchedule.schema"; +import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; + +type SlotsRouterHandlerCache = { + getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler; + reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler; +}; + +const UNSTABLE_HANDLER_CACHE: SlotsRouterHandlerCache = {}; + +/** This should be called getAvailableSlots */ +export const slotsRouter = router({ + getSchedule: publicProcedure.input(ZGetScheduleInputSchema).query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getSchedule) { + UNSTABLE_HANDLER_CACHE.getSchedule = await import("./getSchedule.handler").then( + (mod) => mod.getScheduleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getSchedule) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getSchedule({ + ctx, + input, + }); + }), + reserveSlot: publicProcedure.input(ZReserveSlotInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.reserveSlot) { + UNSTABLE_HANDLER_CACHE.reserveSlot = await import("./reserveSlot.handler").then( + (mod) => mod.reserveSlotHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.reserveSlot) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.reserveSlot({ + ctx: { ...ctx, req: ctx.req as NextApiRequest, res: ctx.res as NextApiResponse }, + input, + }); + }), + // This endpoint has no dependencies, it doesn't need its own file + removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { + const { req, prisma } = ctx; + const uid = req?.cookies?.uid; + if (uid) { + await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); + } + return; + }), +}); diff --git a/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts b/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts new file mode 100644 index 0000000000..43fc975b03 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getSchedule.handler.ts @@ -0,0 +1,11 @@ +import type { TGetScheduleInputSchema } from "./getSchedule.schema"; +import { getSchedule } from "./util"; + +type GetScheduleOptions = { + ctx: Record; + input: TGetScheduleInputSchema; +}; + +export const getScheduleHandler = async ({ input }: GetScheduleOptions) => { + return await getSchedule(input); +}; diff --git a/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts b/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts new file mode 100644 index 0000000000..89ccc014d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getSchedule.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { getScheduleSchema } from "./types"; + +export const ZGetScheduleInputSchema = getScheduleSchema; + +export type TGetScheduleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts new file mode 100644 index 0000000000..9f98448535 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -0,0 +1,61 @@ +import { serialize } from "cookie"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuid } from "uuid"; + +import dayjs from "@calcom/dayjs"; +import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; +import type { PrismaClient } from "@calcom/prisma/client"; + +import { TRPCError } from "@trpc/server"; + +import type { TReserveSlotInputSchema } from "./reserveSlot.schema"; + +interface ReserveSlotOptions { + ctx: { + prisma: PrismaClient; + req?: NextApiRequest | undefined; + res?: NextApiResponse | undefined; + }; + input: TReserveSlotInputSchema; +} +export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => { + const { prisma, req, res } = ctx; + const uid = req?.cookies?.uid || uuid(); + const { slotUtcStartDate, slotUtcEndDate, eventTypeId } = input; + const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); + const eventType = await prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + if (eventType) { + await Promise.all( + eventType.users.map((user) => + prisma.selectedSlots.upsert({ + where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, + update: { + slotUtcStartDate, + slotUtcEndDate, + releaseAt, + eventTypeId, + }, + create: { + userId: user.id, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat: eventType.seatsPerTimeSlot !== null, + }, + }) + ) + ); + } else { + throw new TRPCError({ + message: "Event type not found", + code: "NOT_FOUND", + }); + } + res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); + return; +}; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts new file mode 100644 index 0000000000..74e9fc9942 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { reserveSlotSchema } from "./types"; + +export const ZReserveSlotInputSchema = reserveSlotSchema; + +export type TReserveSlotInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts new file mode 100644 index 0000000000..f387f7059e --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const getScheduleSchema = z + .object({ + // startTime ISOString + startTime: z.string(), + // endTime ISOString + endTime: z.string(), + // Event type ID + eventTypeId: z.number().int().optional(), + // Event type slug + eventTypeSlug: z.string(), + // invitee timezone + timeZone: z.string().optional(), + // or list of users (for dynamic events) + usernameList: z.array(z.string()).optional(), + debug: z.boolean().optional(), + // to handle event types with multiple duration options + duration: z + .string() + .optional() + .transform((val) => val && parseInt(val)), + }) + .refine( + (data) => !!data.eventTypeId || !!data.usernameList, + "Either usernameList or eventTypeId should be filled in." + ); + +export const reserveSlotSchema = z + .object({ + eventTypeId: z.number().int(), + // startTime ISOString + slotUtcStartDate: z.string(), + // endTime ISOString + slotUtcEndDate: z.string(), + }) + .refine( + (data) => !!data.eventTypeId || !!data.slotUtcStartDate || !!data.slotUtcEndDate, + "Either slotUtcStartDate, slotUtcEndDate or eventTypeId should be filled in." + ); + +export type Slot = { + time: string; + userIds?: number[]; + attendees?: number; + bookingUid?: string; + users?: string[]; +}; diff --git a/packages/trpc/server/routers/viewer/slots.ts b/packages/trpc/server/routers/viewer/slots/util.ts similarity index 77% rename from packages/trpc/server/routers/viewer/slots.ts rename to packages/trpc/server/routers/viewer/slots/util.ts index 787d613109..13959a741e 100644 --- a/packages/trpc/server/routers/viewer/slots.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1,77 +1,27 @@ import { SchedulingType } from "@prisma/client"; -import { serialize } from "cookie"; import { countBy } from "lodash"; import { v4 as uuid } from "uuid"; -import { z } from "zod"; import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours"; import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; -import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; import getTimeSlots from "@calcom/lib/slots"; -import type prisma from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; +import prisma from "@calcom/prisma"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; -import { publicProcedure, router } from "../../trpc"; +import type { TGetScheduleInputSchema } from "./getSchedule.schema"; -const getScheduleSchema = z - .object({ - // startTime ISOString - startTime: z.string(), - // endTime ISOString - endTime: z.string(), - // Event type ID - eventTypeId: z.number().int().optional(), - // Event type slug - eventTypeSlug: z.string(), - // invitee timezone - timeZone: z.string().optional(), - // or list of users (for dynamic events) - usernameList: z.array(z.string()).optional(), - debug: z.boolean().optional(), - // to handle event types with multiple duration options - duration: z - .string() - .optional() - .transform((val) => val && parseInt(val)), - }) - .refine( - (data) => !!data.eventTypeId || !!data.usernameList, - "Either usernameList or eventTypeId should be filled in." - ); - -const reverveSlotSchema = z - .object({ - eventTypeId: z.number().int(), - // startTime ISOString - slotUtcStartDate: z.string(), - // endTime ISOString - slotUtcEndDate: z.string(), - }) - .refine( - (data) => !!data.eventTypeId || !!data.slotUtcStartDate || !!data.slotUtcEndDate, - "Either slotUtcStartDate, slotUtcEndDate or eventTypeId should be filled in." - ); - -export type Slot = { - time: string; - userIds?: number[]; - attendees?: number; - bookingUid?: string; - users?: string[]; -}; - -const checkIfIsAvailable = ({ +export const checkIfIsAvailable = ({ time, busy, eventLength, @@ -120,64 +70,8 @@ const checkIfIsAvailable = ({ }); }; -/** This should be called getAvailableSlots */ -export const slotsRouter = router({ - getSchedule: publicProcedure.input(getScheduleSchema).query(async ({ input, ctx }) => { - return await getSchedule(input, ctx); - }), - reserveSlot: publicProcedure.input(reverveSlotSchema).mutation(async ({ ctx, input }) => { - const { prisma, req, res } = ctx; - const uid = req?.cookies?.uid || uuid(); - const { slotUtcStartDate, slotUtcEndDate, eventTypeId } = input; - const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); - const eventType = await prisma.eventType.findUnique({ - where: { id: eventTypeId }, - select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, - }); - if (eventType) { - await Promise.all( - eventType.users.map((user) => - prisma.selectedSlots.upsert({ - where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, - update: { - slotUtcStartDate, - slotUtcEndDate, - releaseAt, - eventTypeId, - }, - create: { - userId: user.id, - eventTypeId, - slotUtcStartDate, - slotUtcEndDate, - uid, - releaseAt, - isSeat: eventType.seatsPerTimeSlot !== null, - }, - }) - ) - ); - } else { - throw new TRPCError({ - message: "Event type not found", - code: "NOT_FOUND", - }); - } - res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); - return; - }), - removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { - const { req, prisma } = ctx; - const uid = req?.cookies?.uid; - if (uid) { - await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); - } - return; - }), -}); - -async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer) { - const eventType = await ctx.prisma.eventType.findUnique({ +export async function getEventType(input: TGetScheduleInputSchema) { + const eventType = await prisma.eventType.findUnique({ where: { id: input.eventTypeId, }, @@ -243,10 +137,10 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer) { +export async function getDynamicEventType(input: TGetScheduleInputSchema) { // For dynamic booking, we need to get and update user credentials, schedule and availability in the eventTypeObject as they're required in the new availability logic const dynamicEventType = getDefaultEvent(input.eventTypeSlug); - const users = await ctx.prisma.user.findMany({ + const users = await prisma.user.findMany({ where: { username: { in: input.usernameList, @@ -270,16 +164,13 @@ async function getDynamicEventType(ctx: { prisma: typeof prisma }, input: z.infe }); } -function getRegularOrDynamicEventType( - ctx: { prisma: typeof prisma }, - input: z.infer -) { +export function getRegularOrDynamicEventType(input: TGetScheduleInputSchema) { const isDynamicBooking = !input.eventTypeId; - return isDynamicBooking ? getDynamicEventType(ctx, input) : getEventType(ctx, input); + return isDynamicBooking ? getDynamicEventType(input) : getEventType(input); } /** This should be called getAvailableSlots */ -export async function getSchedule(input: z.infer, ctx: { prisma: typeof prisma }) { +export async function getSchedule(input: TGetScheduleInputSchema) { if (input.debug === true) { logger.setSettings({ minLevel: "debug" }); } @@ -287,7 +178,7 @@ export async function getSchedule(input: z.infer, ctx: logger.setSettings({ minLevel: "silly" }); } const startPrismaEventTypeGet = performance.now(); - const eventType = await getRegularOrDynamicEventType(ctx, input); + const eventType = await getRegularOrDynamicEventType(input); const endPrismaEventTypeGet = performance.now(); logger.debug( `Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${ @@ -401,7 +292,7 @@ export async function getSchedule(input: z.infer, ctx: // Load cached busy slots const selectedSlots = /* FIXME: For some reason this returns undefined while testing in Jest */ - (await ctx.prisma.selectedSlots.findMany({ + (await prisma.selectedSlots.findMany({ where: { userId: { in: usersWithCredentials.map((user) => user.id) }, releaseAt: { gt: dayjs.utc().format() }, @@ -415,7 +306,7 @@ export async function getSchedule(input: z.infer, ctx: eventTypeId: true, }, })) || []; - await ctx.prisma.selectedSlots.deleteMany({ + await prisma.selectedSlots.deleteMany({ where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } }, }); diff --git a/packages/trpc/server/routers/viewer/sso.tsx b/packages/trpc/server/routers/viewer/sso.tsx deleted file mode 100644 index d4dfe3a44c..0000000000 --- a/packages/trpc/server/routers/viewer/sso.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { z } from "zod"; - -import jackson from "@calcom/features/ee/sso/lib/jackson"; -import { - samlProductID, - samlTenantID, - tenantPrefix, - canAccess, - oidcPath, -} from "@calcom/features/ee/sso/lib/saml"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -export const ssoRouter = router({ - // Retrieve SSO Connection - get: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - }) - ) - .query(async ({ ctx, input }) => { - const { teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - const { connectionController, samlSPConfig } = await jackson(); - - // Retrieve the SP SAML Config - const SPConfig = await samlSPConfig.get(); - - try { - const connections = await connectionController.getConnections({ - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - - if (connections.length === 0) { - return null; - } - - const type = "idpMetadata" in connections[0] ? "saml" : "oidc"; - - return { - ...connections[0], - type, - acsUrl: type === "saml" ? SPConfig.acsUrl : null, - entityId: type === "saml" ? SPConfig.entityId : null, - callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null, - }; - } catch (err) { - console.error("Error getting SSO connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." }); - } - }), - // Update the SAML Connection - update: authedProcedure - .input( - z.object({ - encodedRawMetadata: z.string(), - teamId: z.union([z.number(), z.null()]), - }) - ) - .mutation(async ({ ctx, input }) => { - const { connectionController } = await jackson(); - - const { encodedRawMetadata, teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - try { - return await connectionController.createSAMLConnection({ - encodedRawMetadata, - defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/saml-idp`, - redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - } catch (err) { - console.error("Error updating SAML connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." }); - } - }), - // Delete the SAML Connection - delete: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - }) - ) - .mutation(async ({ ctx, input }) => { - const { connectionController } = await jackson(); - - const { teamId } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - try { - return await connectionController.deleteConnections({ - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - }); - } catch (err) { - console.error("Error deleting SAML connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." }); - } - }), - - // Update the OIDC Connection - updateOIDC: authedProcedure - .input( - z.object({ - teamId: z.union([z.number(), z.null()]), - clientId: z.string(), - clientSecret: z.string(), - wellKnownUrl: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { teamId, clientId, clientSecret, wellKnownUrl } = input; - - const { message, access } = await canAccess(ctx.user, teamId); - - if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); - } - - const { connectionController } = await jackson(); - - try { - return await connectionController.createOIDCConnection({ - defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`, - redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), - tenant: teamId ? tenantPrefix + teamId : samlTenantID, - product: samlProductID, - oidcClientId: clientId, - oidcClientSecret: clientSecret, - oidcDiscoveryUrl: wellKnownUrl, - }); - } catch (err) { - console.error("Error updating OIDC connection", err); - throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." }); - } - }), -}); diff --git a/packages/trpc/server/routers/viewer/sso/_router.tsx b/packages/trpc/server/routers/viewer/sso/_router.tsx new file mode 100644 index 0000000000..0751262350 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/_router.tsx @@ -0,0 +1,86 @@ +import { router, authedProcedure } from "../../../trpc"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZUpdateOIDCInputSchema } from "./updateOIDC.schema"; + +type SSORouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + updateOIDC?: typeof import("./updateOIDC.handler").updateOIDCHandler; +}; + +const UNSTABLE_HANDLER_CACHE: SSORouterHandlerCache = {}; + +export const ssoRouter = router({ + // Retrieve SSO Connection + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + // Update the SAML Connection + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + // Delete the SAML Connection + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + // Update the OIDC Connection + updateOIDC: authedProcedure.input(ZUpdateOIDCInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateOIDC) { + UNSTABLE_HANDLER_CACHE.updateOIDC = await import("./updateOIDC.handler").then( + (mod) => mod.updateOIDCHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateOIDC) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateOIDC({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/sso/delete.handler.ts b/packages/trpc/server/routers/viewer/sso/delete.handler.ts new file mode 100644 index 0000000000..7e94cf2a90 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/delete.handler.ts @@ -0,0 +1,39 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { connectionController } = await jackson(); + + const { teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + try { + return await connectionController.deleteConnections({ + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + } catch (err) { + console.error("Error deleting SAML connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/delete.schema.ts b/packages/trpc/server/routers/viewer/sso/delete.schema.ts new file mode 100644 index 0000000000..0ad5d3c40f --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/get.handler.ts b/packages/trpc/server/routers/viewer/sso/get.handler.ts new file mode 100644 index 0000000000..7e6aa0153d --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/get.handler.ts @@ -0,0 +1,62 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { + canAccess, + oidcPath, + samlProductID, + samlTenantID, + tenantPrefix, +} from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const { teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + const { connectionController, samlSPConfig } = await jackson(); + + // Retrieve the SP SAML Config + const SPConfig = await samlSPConfig.get(); + + try { + const connections = await connectionController.getConnections({ + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + + if (connections.length === 0) { + return null; + } + + const type = "idpMetadata" in connections[0] ? "saml" : "oidc"; + + return { + ...connections[0], + type, + acsUrl: type === "saml" ? SPConfig.acsUrl : null, + entityId: type === "saml" ? SPConfig.entityId : null, + callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null, + }; + } catch (err) { + console.error("Error getting SSO connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/get.schema.ts b/packages/trpc/server/routers/viewer/sso/get.schema.ts new file mode 100644 index 0000000000..044011e4e8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/update.handler.ts b/packages/trpc/server/routers/viewer/sso/update.handler.ts new file mode 100644 index 0000000000..2b150c4764 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/update.handler.ts @@ -0,0 +1,42 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { connectionController } = await jackson(); + + const { encodedRawMetadata, teamId } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + try { + return await connectionController.createSAMLConnection({ + encodedRawMetadata, + defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/saml-idp`, + redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + }); + } catch (err) { + console.error("Error updating SAML connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/update.schema.ts b/packages/trpc/server/routers/viewer/sso/update.schema.ts new file mode 100644 index 0000000000..e5e456c9d9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/update.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + encodedRawMetadata: z.string(), + teamId: z.union([z.number(), z.null()]), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts b/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts new file mode 100644 index 0000000000..8ecd5d76de --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/updateOIDC.handler.ts @@ -0,0 +1,44 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { canAccess, samlProductID, samlTenantID, tenantPrefix } from "@calcom/features/ee/sso/lib/saml"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateOIDCInputSchema } from "./updateOIDC.schema"; + +type UpdateOIDCOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateOIDCInputSchema; +}; + +export const updateOIDCHandler = async ({ ctx, input }: UpdateOIDCOptions) => { + const { teamId, clientId, clientSecret, wellKnownUrl } = input; + + const { message, access } = await canAccess(ctx.user, teamId); + + if (!access) { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); + } + + const { connectionController } = await jackson(); + + try { + return await connectionController.createOIDCConnection({ + defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`, + redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), + tenant: teamId ? tenantPrefix + teamId : samlTenantID, + product: samlProductID, + oidcClientId: clientId, + oidcClientSecret: clientSecret, + oidcDiscoveryUrl: wellKnownUrl, + }); + } catch (err) { + console.error("Error updating OIDC connection", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." }); + } +}; diff --git a/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts b/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts new file mode 100644 index 0000000000..9adefc1f7d --- /dev/null +++ b/packages/trpc/server/routers/viewer/sso/updateOIDC.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZUpdateOIDCInputSchema = z.object({ + teamId: z.union([z.number(), z.null()]), + clientId: z.string(), + clientSecret: z.string(), + wellKnownUrl: z.string(), +}); + +export type TUpdateOIDCInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx deleted file mode 100644 index 31c6f4ad62..0000000000 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ /dev/null @@ -1,739 +0,0 @@ -import { MembershipRole, Prisma } from "@prisma/client"; -import { randomBytes } from "crypto"; -import { z } from "zod"; - -import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; -import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import { sendTeamInviteEmail } from "@calcom/emails"; -import { - cancelTeamSubscriptionFromStripe, - purchaseTeamSubscription, - updateQuantitySubscriptionFromStripe, -} from "@calcom/features/ee/teams/lib/payments"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; -import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams"; -import slugify from "@calcom/lib/slugify"; -import { - closeComDeleteTeam, - closeComDeleteTeamMembership, - closeComUpdateTeam, - closeComUpsertTeamUser, -} from "@calcom/lib/sync/SyncServiceManager"; -import { availabilityUserSelect } from "@calcom/prisma"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; - -import { authedProcedure, router } from "../../trpc"; - -const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); -export const viewerTeamsRouter = router({ - // Retrieves team by id - get: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); - if (!team) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - } - const membership = team?.members.find((membership) => membership.id === ctx.user.id); - return { - ...team, - safeBio: markdownToSafeHTML(team.bio), - membership: { - role: membership?.role as MembershipRole, - accepted: membership?.accepted, - }, - }; - }), - // Returns teams I a member of - list: authedProcedure.query(async ({ ctx }) => { - const memberships = await ctx.prisma.membership.findMany({ - where: { - userId: ctx.user.id, - }, - include: { - team: true, - }, - orderBy: { role: "desc" }, - }); - - return memberships.map(({ team, ...membership }) => ({ - role: membership.role, - accepted: membership.accepted, - ...team, - })); - }), - create: authedProcedure - .input( - z.object({ - name: z.string(), - slug: z.string().transform((val) => slugify(val.trim())), - logo: z - .string() - .optional() - .nullable() - .transform((v) => v || null), - }) - ) - .mutation(async ({ ctx, input }) => { - const { slug, name, logo } = input; - - const slugCollisions = await ctx.prisma.team.findFirst({ - where: { - slug: slug, - }, - }); - - if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_url_taken" }); - - // Ensure that the user is not duplicating a requested team - const duplicatedRequest = await ctx.prisma.team.findFirst({ - where: { - members: { - some: { - userId: ctx.user.id, - }, - }, - metadata: { - path: ["requestedSlug"], - equals: slug, - }, - }, - }); - - if (duplicatedRequest) { - return duplicatedRequest; - } - - const createTeam = await ctx.prisma.team.create({ - data: { - name, - logo, - members: { - create: { - userId: ctx.user.id, - role: MembershipRole.OWNER, - accepted: true, - }, - }, - metadata: { - requestedSlug: slug, - }, - ...(!IS_TEAM_BILLING_ENABLED && { slug }), - }, - }); - - // Sync Services: Close.com - closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); - - return createTeam; - }), - // Allows team owner to update team metadata - update: authedProcedure - .input( - z.object({ - id: z.number(), - bio: z.string().optional(), - name: z.string().optional(), - logo: z.string().optional(), - slug: z.string().optional(), - hideBranding: z.boolean().optional(), - hideBookATeamMember: z.boolean().optional(), - brandColor: z.string().optional(), - darkBrandColor: z.string().optional(), - theme: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (input.slug) { - const userConflict = await ctx.prisma.team.findMany({ - where: { - slug: input.slug, - }, - }); - if (userConflict.some((t) => t.id !== input.id)) return; - } - - const prevTeam = await ctx.prisma.team.findFirst({ - where: { - id: input.id, - }, - }); - - if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - - const data: Prisma.TeamUpdateArgs["data"] = { - name: input.name, - logo: input.logo, - bio: input.bio, - hideBranding: input.hideBranding, - hideBookATeamMember: input.hideBookATeamMember, - brandColor: input.brandColor, - darkBrandColor: input.darkBrandColor, - theme: input.theme, - }; - - if ( - input.slug && - IS_TEAM_BILLING_ENABLED && - /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ - !prevTeam.slug - ) { - // Save it on the metadata so we can use it later - data.metadata = { - requestedSlug: input.slug, - }; - } else { - data.slug = input.slug; - - // If we save slug, we don't need the requestedSlug anymore - const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata); - if (metadataParse.success) { - const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; - data.metadata = { - ...cleanMetadata, - }; - } - } - - const updatedTeam = await ctx.prisma.team.update({ - where: { id: input.id }, - data, - }); - - // Sync Services: Close.com - if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); - }), - delete: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - - if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); - - // delete all memberships - await ctx.prisma.membership.deleteMany({ - where: { - teamId: input.teamId, - }, - }); - - const deletedTeam = await ctx.prisma.team.delete({ - where: { - id: input.teamId, - }, - }); - - // Sync Services: Close.cm - closeComDeleteTeam(deletedTeam); - }), - removeMember: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const isAdmin = await isTeamAdmin(ctx.user?.id, input.teamId); - if (!isAdmin && ctx.user?.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); - // Only a team owner can remove another team owner. - if ( - (await isTeamOwner(input.memberId, input.teamId)) && - !(await isTeamOwner(ctx.user?.id, input.teamId)) - ) - throw new TRPCError({ code: "UNAUTHORIZED" }); - if (ctx.user?.id === input.memberId && isAdmin) - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not remove yourself from a team you own.", - }); - - const membership = await ctx.prisma.membership.delete({ - where: { - userId_teamId: { userId: input.memberId, teamId: input.teamId }, - }, - include: { - user: true, - }, - }); - - // Deleted managed event types from this team from this member - await ctx.prisma.eventType.deleteMany({ - where: { parent: { teamId: input.teamId }, userId: membership.userId }, - }); - - // Sync Services - closeComDeleteTeamMembership(membership.user); - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); - }), - inviteMember: authedProcedure - .input( - z.object({ - teamId: z.number(), - usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.toLowerCase()), - role: z.nativeEnum(MembershipRole), - language: z.string(), - sendEmailInvitation: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - - const translation = await getTranslation(input.language ?? "en", "common"); - - const team = await ctx.prisma.team.findFirst({ - where: { - id: input.teamId, - }, - }); - - if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - - const invitee = await ctx.prisma.user.findFirst({ - where: { - OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], - }, - }); - - if (!invitee) { - // liberal email match - - if (!isEmail(input.usernameOrEmail)) - throw new TRPCError({ - code: "NOT_FOUND", - message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, - }); - - // valid email given, create User and add to team - await ctx.prisma.user.create({ - data: { - email: input.usernameOrEmail, - invitedTo: input.teamId, - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - }, - }, - }, - }); - - const token: string = randomBytes(32).toString("hex"); - - await ctx.prisma.verificationToken.create({ - data: { - identifier: input.usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - }, - }); - if (ctx?.user?.name && team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: input.usernameOrEmail, - teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, - isCalcomMember: false, - }); - } - } else { - // create provisional membership - try { - await ctx.prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); - } - } else throw e; - } - - let sendTo = input.usernameOrEmail; - if (!isEmail(input.usernameOrEmail)) { - sendTo = invitee.email; - } - // inform user of membership by email - if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: sendTo, - teamName: team.name, - joinLink: WEBAPP_URL + "/settings/teams", - isCalcomMember: true, - }); - } - } - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); - return input; - }), - acceptOrLeave: authedProcedure - .input( - z.object({ - teamId: z.number(), - accept: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (input.accept) { - const membership = await ctx.prisma.membership.update({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - data: { - accepted: true, - }, - include: { - team: true, - }, - }); - - closeComUpsertTeamUser(membership.team, ctx.user, membership.role); - } else { - try { - //get team owner so we can alter their subscription seat count - const teamOwner = await ctx.prisma.membership.findFirst({ - where: { teamId: input.teamId, role: MembershipRole.OWNER }, - include: { team: true }, - }); - - const membership = await ctx.prisma.membership.delete({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - }); - - // Sync Services: Close.com - if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); - } catch (e) { - console.log(e); - } - } - }), - changeMemberRole: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - role: z.nativeEnum(MembershipRole), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - // Only owners can award owner role. - if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - const memberships = await ctx.prisma.membership.findMany({ - where: { - teamId: input.teamId, - }, - }); - - const targetMembership = memberships.find((m) => m.userId === input.memberId); - const myMembership = memberships.find((m) => m.userId === ctx.user.id); - const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER); - - if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change the role of an owner if you are an admin.", - }); - } - - if (!teamHasMoreThanOneOwner) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change the role of the only owner of a team.", - }); - } - - if ( - myMembership?.role === MembershipRole.ADMIN && - input.memberId === ctx.user.id && - input.role !== MembershipRole.MEMBER - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can not change yourself to a higher role.", - }); - } - - const membership = await ctx.prisma.membership.update({ - where: { - userId_teamId: { userId: input.memberId, teamId: input.teamId }, - }, - data: { - role: input.role, - }, - include: { - team: true, - user: true, - }, - }); - - // Sync Services: Close.com - closeComUpsertTeamUser(membership.team, membership.user, membership.role); - }), - getMemberAvailability: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - timezone: z.string(), - dateFrom: z.string(), - dateTo: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const team = await isTeamMember(ctx.user?.id, input.teamId); - if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); - - // verify member is in team - const members = await ctx.prisma.membership.findMany({ - where: { teamId: input.teamId }, - include: { - user: { - select: { - credentials: true, // needed for getUserAvailability - ...availabilityUserSelect, - }, - }, - }, - }); - const member = members?.find((m) => m.userId === input.memberId); - if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); - if (!member.user.username) - throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); - - // get availability for this member - return await getUserAvailability( - { - username: member.user.username, - dateFrom: input.dateFrom, - dateTo: input.dateTo, - }, - { user: member.user } - ); - }), - getMembershipbyUser: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - if (ctx.user.id !== input.memberId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You cannot view memberships that are not your own.", - }); - } - - return await ctx.prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: input.memberId, - teamId: input.teamId, - }, - }, - }); - }), - updateMembership: authedProcedure - .input( - z.object({ - teamId: z.number(), - memberId: z.number(), - disableImpersonation: z.boolean(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (ctx.user.id !== input.memberId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You cannot edit memberships that are not your own.", - }); - } - - return await ctx.prisma.membership.update({ - where: { - userId_teamId: { - userId: input.memberId, - teamId: input.teamId, - }, - }, - data: { - disableImpersonation: input.disableImpersonation, - }, - }); - }), - publish: authedProcedure - .input( - z.object({ - teamId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - const { teamId: id } = input; - - const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } }); - - if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - - const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); - - if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); - - // if payment needed, respond with checkout url - if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ - teamId: prevTeam.id, - seats: prevTeam.members.length, - userId: ctx.user.id, - }); - if (!checkoutSession.url) - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed retrieving a checkout session URL.", - }); - return { url: checkoutSession.url, message: "Payment required to publish team" }; - } - - if (!metadata.data?.requestedSlug) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); - } - - const { requestedSlug, ...newMetadata } = metadata.data; - let updatedTeam: Awaited>; - - try { - updatedTeam = await ctx.prisma.team.update({ - where: { id }, - data: { - slug: requestedSlug, - metadata: { ...newMetadata }, - }, - }); - } catch (error) { - const { message } = getRequestedSlugError(error, requestedSlug); - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); - } - - // Sync Services: Close.com - closeComUpdateTeam(prevTeam, updatedTeam); - - return { - url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, - message: "Team published successfully", - }; - }), - /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ - getUpgradeable: authedProcedure.query(async ({ ctx }) => { - if (!IS_TEAM_BILLING_ENABLED) return []; - let { teams } = await ctx.prisma.user.findUniqueOrThrow({ - where: { id: ctx.user.id }, - include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, - }); - /** We only need to return teams that don't have a `subscriptionId` on their metadata */ - teams = teams.filter((m) => { - const metadata = teamMetadataSchema.safeParse(m.team.metadata); - if (metadata.success && metadata.data?.subscriptionId) return false; - return true; - }); - return teams; - }), - listMembers: authedProcedure - .input( - z.object({ - teamIds: z.number().array().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const teams = await ctx.prisma.team.findMany({ - where: { - id: { - in: input.teamIds, - }, - members: { - some: { - user: { - id: ctx.user.id, - }, - accepted: true, - }, - }, - }, - select: { - members: { - select: { - user: { - select: { - id: true, - name: true, - username: true, - }, - }, - }, - }, - }, - }); - type UserMap = Record; - // flattern users to be unique by id - const users = teams - .flatMap((t) => t.members) - .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); - return Object.values(users); - }), - hasTeamPlan: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - const hasTeamPlan = await ctx.prisma.membership.findFirst({ - where: { - userId, - team: { - slug: { - not: null, - }, - }, - }, - }); - return { hasTeamPlan: !!hasTeamPlan }; - }), - listInvites: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - return await ctx.prisma.membership.findMany({ - where: { - user: { - id: userId, - }, - accepted: false, - }, - }); - }), -}); diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx new file mode 100644 index 0000000000..5d3aca2ba3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -0,0 +1,336 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; +import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; +import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; +import { ZInviteMemberInputSchema } from "./inviteMember.schema"; +import { ZListMembersInputSchema } from "./listMembers.schema"; +import { ZPublishInputSchema } from "./publish.schema"; +import { ZRemoveMemberInputSchema } from "./removeMember.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; + +type TeamsRouterHandlerCache = { + get?: typeof import("./get.handler").getHandler; + list?: typeof import("./list.handler").listHandler; + create?: typeof import("./create.handler").createHandler; + update?: typeof import("./update.handler").updateHandler; + delete?: typeof import("./delete.handler").deleteHandler; + removeMember?: typeof import("./removeMember.handler").removeMemberHandler; + inviteMember?: typeof import("./inviteMember.handler").inviteMemberHandler; + acceptOrLeave?: typeof import("./acceptOrLeave.handler").acceptOrLeaveHandler; + changeMemberRole?: typeof import("./changeMemberRole.handler").changeMemberRoleHandler; + getMemberAvailability?: typeof import("./getMemberAvailability.handler").getMemberAvailabilityHandler; + getMembershipbyUser?: typeof import("./getMembershipbyUser.handler").getMembershipbyUserHandler; + updateMembership?: typeof import("./updateMembership.handler").updateMembershipHandler; + publish?: typeof import("./publish.handler").publishHandler; + getUpgradeable?: typeof import("./getUpgradeable.handler").getUpgradeableHandler; + listMembers?: typeof import("./listMembers.handler").listMembersHandler; + hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler; + listInvites?: typeof import("./listInvites.handler").listInvitesHandler; +}; + +const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {}; + +export const viewerTeamsRouter = router({ + // Retrieves team by id + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + // Returns teams I a member of + list: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + // Allows team owner to update team metadata + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.removeMember) { + UNSTABLE_HANDLER_CACHE.removeMember = await import("./removeMember.handler").then( + (mod) => mod.removeMemberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.removeMember) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.removeMember({ + ctx, + input, + }); + }), + + inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.inviteMember) { + UNSTABLE_HANDLER_CACHE.inviteMember = await import("./inviteMember.handler").then( + (mod) => mod.inviteMemberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.inviteMember) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.inviteMember({ + ctx, + input, + }); + }), + + acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { + UNSTABLE_HANDLER_CACHE.acceptOrLeave = await import("./acceptOrLeave.handler").then( + (mod) => mod.acceptOrLeaveHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.acceptOrLeave({ + ctx, + input, + }); + }), + + changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { + UNSTABLE_HANDLER_CACHE.changeMemberRole = await import("./changeMemberRole.handler").then( + (mod) => mod.changeMemberRoleHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.changeMemberRole({ + ctx, + input, + }); + }), + + getMemberAvailability: authedProcedure + .input(ZGetMemberAvailabilityInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { + UNSTABLE_HANDLER_CACHE.getMemberAvailability = await import("./getMemberAvailability.handler").then( + (mod) => mod.getMemberAvailabilityHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getMemberAvailability({ + ctx, + input, + }); + }), + + getMembershipbyUser: authedProcedure + .input(ZGetMembershipbyUserInputSchema) + .query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { + UNSTABLE_HANDLER_CACHE.getMembershipbyUser = await import("./getMembershipbyUser.handler").then( + (mod) => mod.getMembershipbyUserHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getMembershipbyUser({ + ctx, + input, + }); + }), + + updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.updateMembership) { + UNSTABLE_HANDLER_CACHE.updateMembership = await import("./updateMembership.handler").then( + (mod) => mod.updateMembershipHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.updateMembership) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.updateMembership({ + ctx, + input, + }); + }), + + publish: authedProcedure.input(ZPublishInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.publish) { + UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.publish) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.publish({ + ctx, + input, + }); + }), + + /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ + getUpgradeable: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { + UNSTABLE_HANDLER_CACHE.getUpgradeable = await import("./getUpgradeable.handler").then( + (mod) => mod.getUpgradeableHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUpgradeable({ + ctx, + }); + }), + + listMembers: authedProcedure.input(ZListMembersInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.listMembers) { + UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then( + (mod) => mod.listMembersHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listMembers) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listMembers({ + ctx, + input, + }); + }), + + hasTeamPlan: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { + UNSTABLE_HANDLER_CACHE.hasTeamPlan = await import("./hasTeamPlan.handler").then( + (mod) => mod.hasTeamPlanHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.hasTeamPlan({ + ctx, + }); + }), + + listInvites: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.listInvites) { + UNSTABLE_HANDLER_CACHE.listInvites = await import("./listInvites.handler").then( + (mod) => mod.listInvitesHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listInvites) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listInvites({ + ctx, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts new file mode 100644 index 0000000000..a2b54bcffa --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -0,0 +1,51 @@ +import { MembershipRole } from "@prisma/client"; + +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; + +type AcceptOrLeaveOptions = { + ctx: { + user: NonNullable; + }; + input: TAcceptOrLeaveInputSchema; +}; + +export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) => { + if (input.accept) { + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, + }, + data: { + accepted: true, + }, + include: { + team: true, + }, + }); + + closeComUpsertTeamUser(membership.team, ctx.user, membership.role); + } else { + try { + //get team owner so we can alter their subscription seat count + const teamOwner = await prisma.membership.findFirst({ + where: { teamId: input.teamId, role: MembershipRole.OWNER }, + include: { team: true }, + }); + + const membership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, + }, + }); + + // Sync Services: Close.com + if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); + } catch (e) { + console.log(e); + } + } +}; diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts new file mode 100644 index 0000000000..546466f9ad --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAcceptOrLeaveInputSchema = z.object({ + teamId: z.number(), + accept: z.boolean(), +}); + +export type TAcceptOrLeaveInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts new file mode 100644 index 0000000000..bb91f2da4f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts @@ -0,0 +1,74 @@ +import { MembershipRole } from "@prisma/client"; + +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; + +type ChangeMemberRoleOptions = { + ctx: { + user: NonNullable; + }; + input: TChangeMemberRoleInputSchema; +}; + +export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + // Only owners can award owner role. + if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + const memberships = await prisma.membership.findMany({ + where: { + teamId: input.teamId, + }, + }); + + const targetMembership = memberships.find((m) => m.userId === input.memberId); + const myMembership = memberships.find((m) => m.userId === ctx.user.id); + const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER); + + if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change the role of an owner if you are an admin.", + }); + } + + if (!teamHasMoreThanOneOwner) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change the role of the only owner of a team.", + }); + } + + if ( + myMembership?.role === MembershipRole.ADMIN && + input.memberId === ctx.user.id && + input.role !== MembershipRole.MEMBER + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not change yourself to a higher role.", + }); + } + + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: input.memberId, teamId: input.teamId }, + }, + data: { + role: input.role, + }, + include: { + team: true, + user: true, + }, + }); + + // Sync Services: Close.com + closeComUpsertTeamUser(membership.team, membership.user, membership.role); +}; diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts new file mode 100644 index 0000000000..17ea122d67 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts @@ -0,0 +1,10 @@ +import { MembershipRole } from "@prisma/client"; +import { z } from "zod"; + +export const ZChangeMemberRoleInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + role: z.nativeEnum(MembershipRole), +}); + +export type TChangeMemberRoleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts new file mode 100644 index 0000000000..7f4ae162e2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -0,0 +1,71 @@ +import { MembershipRole } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { slug, name, logo } = input; + + const slugCollisions = await prisma.team.findFirst({ + where: { + slug: slug, + }, + }); + + if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_url_taken" }); + + // Ensure that the user is not duplicating a requested team + const duplicatedRequest = await prisma.team.findFirst({ + where: { + members: { + some: { + userId: ctx.user.id, + }, + }, + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + }); + + if (duplicatedRequest) { + return duplicatedRequest; + } + + const createTeam = await prisma.team.create({ + data: { + name, + logo, + members: { + create: { + userId: ctx.user.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + metadata: { + requestedSlug: slug, + }, + ...(!IS_TEAM_BILLING_ENABLED && { slug }), + }, + }); + + // Sync Services: Close.com + closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); + + return createTeam; +}; diff --git a/packages/trpc/server/routers/viewer/teams/create.schema.ts b/packages/trpc/server/routers/viewer/teams/create.schema.ts new file mode 100644 index 0000000000..846228be2b --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/create.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import slugify from "@calcom/lib/slugify"; + +export const ZCreateInputSchema = z.object({ + name: z.string(), + slug: z.string().transform((val) => slugify(val.trim())), + logo: z + .string() + .optional() + .nullable() + .transform((v) => v || null), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/delete.handler.ts b/packages/trpc/server/routers/viewer/teams/delete.handler.ts new file mode 100644 index 0000000000..5bde167011 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/delete.handler.ts @@ -0,0 +1,39 @@ +import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); + + // delete all memberships + await prisma.membership.deleteMany({ + where: { + teamId: input.teamId, + }, + }); + + const deletedTeam = await prisma.team.delete({ + where: { + id: input.teamId, + }, + }); + + // Sync Services: Close.cm + closeComDeleteTeam(deletedTeam); +}; diff --git a/packages/trpc/server/routers/viewer/teams/delete.schema.ts b/packages/trpc/server/routers/viewer/teams/delete.schema.ts new file mode 100644 index 0000000000..01556322c6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + teamId: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts new file mode 100644 index 0000000000..05f53253a0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -0,0 +1,35 @@ +import type { MembershipRole } from "@prisma/client"; + +import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); + + if (!team) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + } + + const membership = team?.members.find((membership) => membership.id === ctx.user.id); + + return { + ...team, + safeBio: markdownToSafeHTML(team.bio), + membership: { + role: membership?.role as MembershipRole, + accepted: membership?.accepted, + }, + }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/get.schema.ts b/packages/trpc/server/routers/viewer/teams/get.schema.ts new file mode 100644 index 0000000000..b1520391d0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + teamId: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts new file mode 100644 index 0000000000..97b706c599 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -0,0 +1,48 @@ +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { isTeamMember } from "@calcom/lib/server/queries/teams"; +import { availabilityUserSelect } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; + +type GetMemberAvailabilityOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMemberAvailabilityInputSchema; +}; + +export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvailabilityOptions) => { + const team = await isTeamMember(ctx.user?.id, input.teamId); + if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); + + // verify member is in team + const members = await prisma.membership.findMany({ + where: { teamId: input.teamId }, + include: { + user: { + select: { + credentials: true, // needed for getUserAvailability + ...availabilityUserSelect, + }, + }, + }, + }); + const member = members?.find((m) => m.userId === input.memberId); + if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + if (!member.user.username) + throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); + + // get availability for this member + return await getUserAvailability( + { + username: member.user.username, + dateFrom: input.dateFrom, + dateTo: input.dateTo, + }, + { user: member.user } + ); +}; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts new file mode 100644 index 0000000000..07b62c1296 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZGetMemberAvailabilityInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + timezone: z.string(), + dateFrom: z.string(), + dateTo: z.string(), +}); + +export type TGetMemberAvailabilityInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts new file mode 100644 index 0000000000..55794cb47f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts @@ -0,0 +1,31 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; + +type GetMembershipbyUserOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMembershipbyUserInputSchema; +}; + +export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipbyUserOptions) => { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot view memberships that are not your own.", + }); + } + + return await prisma.membership.findUnique({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts new file mode 100644 index 0000000000..5488cf22d7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetMembershipbyUserInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), +}); + +export type TGetMembershipbyUserInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts new file mode 100644 index 0000000000..22f766a11a --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts @@ -0,0 +1,27 @@ +import { MembershipRole } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type GetUpgradeableOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => { + if (!IS_TEAM_BILLING_ENABLED) return []; + let { teams } = await prisma.user.findUniqueOrThrow({ + where: { id: ctx.user.id }, + include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, + }); + /** We only need to return teams that don't have a `subscriptionId` on their metadata */ + teams = teams.filter((m) => { + const metadata = teamMetadataSchema.safeParse(m.team.metadata); + if (metadata.success && metadata.data?.subscriptionId) return false; + return true; + }); + return teams; +}; diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts new file mode 100644 index 0000000000..b4de3142de --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -0,0 +1,24 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type HasTeamPlanOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { + const userId = ctx.user.id; + + const hasTeamPlan = await prisma.membership.findFirst({ + where: { + userId, + team: { + slug: { + not: null, + }, + }, + }, + }); + return { hasTeamPlan: !!hasTeamPlan }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts new file mode 100644 index 0000000000..1c10fdf2db --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts @@ -0,0 +1,126 @@ +import { MembershipRole, Prisma } from "@prisma/client"; +import { randomBytes } from "crypto"; + +import { sendTeamInviteEmail } from "@calcom/emails"; +import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TInviteMemberInputSchema } from "./inviteMember.schema"; +import { isEmail } from "./util"; + +type InviteMemberOptions = { + ctx: { + user: NonNullable; + }; + input: TInviteMemberInputSchema; +}; + +export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + const translation = await getTranslation(input.language ?? "en", "common"); + + const team = await prisma.team.findFirst({ + where: { + id: input.teamId, + }, + }); + + if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + + const invitee = await prisma.user.findFirst({ + where: { + OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], + }, + }); + + if (!invitee) { + // liberal email match + + if (!isEmail(input.usernameOrEmail)) + throw new TRPCError({ + code: "NOT_FOUND", + message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, + }); + + // valid email given, create User and add to team + await prisma.user.create({ + data: { + email: input.usernameOrEmail, + invitedTo: input.teamId, + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + }, + }, + }, + }); + + const token: string = randomBytes(32).toString("hex"); + + await prisma.verificationToken.create({ + data: { + identifier: input.usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + }, + }); + if (ctx?.user?.name && team?.name) { + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: input.usernameOrEmail, + teamName: team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, + isCalcomMember: false, + }); + } + } else { + // create provisional membership + try { + await prisma.membership.create({ + data: { + teamId: input.teamId, + userId: invitee.id, + role: input.role as MembershipRole, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This user is a member of this team / has a pending invitation.", + }); + } + } else throw e; + } + + let sendTo = input.usernameOrEmail; + if (!isEmail(input.usernameOrEmail)) { + sendTo = invitee.email; + } + // inform user of membership by email + if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: sendTo, + teamName: team.name, + joinLink: WEBAPP_URL + "/settings/teams", + isCalcomMember: true, + }); + } + } + if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); + return input; +}; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts new file mode 100644 index 0000000000..bf728981d1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts @@ -0,0 +1,12 @@ +import { MembershipRole } from "@prisma/client"; +import { z } from "zod"; + +export const ZInviteMemberInputSchema = z.object({ + teamId: z.number(), + usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.toLowerCase()), + role: z.nativeEnum(MembershipRole), + language: z.string(), + sendEmailInvitation: z.boolean(), +}); + +export type TInviteMemberInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts new file mode 100644 index 0000000000..a810c32d89 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -0,0 +1,27 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listHandler = async ({ ctx }: ListOptions) => { + const memberships = await prisma.membership.findMany({ + where: { + userId: ctx.user.id, + }, + include: { + team: true, + }, + orderBy: { role: "desc" }, + }); + + return memberships.map(({ team, ...membership }) => ({ + role: membership.role, + accepted: membership.accepted, + ...team, + })); +}; diff --git a/packages/trpc/server/routers/viewer/teams/list.schema.ts b/packages/trpc/server/routers/viewer/teams/list.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/list.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts new file mode 100644 index 0000000000..d4ffed6217 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts @@ -0,0 +1,20 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +type ListInvitesOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => { + const userId = ctx.user.id; + return await prisma.membership.findMany({ + where: { + user: { + id: userId, + }, + accepted: false, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts b/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listInvites.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts new file mode 100644 index 0000000000..fde52d5a1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -0,0 +1,52 @@ +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListMembersInputSchema } from "./listMembers.schema"; +import type { PrismaClient } from ".prisma/client"; + +type ListMembersOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TListMembersInputSchema; +}; + +export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) => { + const { prisma } = ctx; + const teams = await prisma.team.findMany({ + where: { + id: { + in: input.teamIds, + }, + members: { + some: { + user: { + id: ctx.user.id, + }, + accepted: true, + }, + }, + }, + select: { + members: { + select: { + user: { + select: { + id: true, + name: true, + username: true, + }, + }, + }, + }, + }, + }); + + type UserMap = Record; + // flattern users to be unique by id + const users = teams + .flatMap((t) => t.members) + .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); + + return Object.values(users); +}; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts b/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts new file mode 100644 index 0000000000..ff83f6c100 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listMembers.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZListMembersInputSchema = z.object({ + teamIds: z.number().array().optional(), +}); + +export type TListMembersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts new file mode 100644 index 0000000000..f2d5d95769 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -0,0 +1,75 @@ +import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; +import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; +import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TPublishInputSchema } from "./publish.schema"; + +type PublishOptions = { + ctx: { + user: NonNullable; + }; + input: TPublishInputSchema; +}; + +export const publishHandler = async ({ ctx, input }: PublishOptions) => { + if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + const { teamId: id } = input; + + const prevTeam = await prisma.team.findFirst({ where: { id }, include: { members: true } }); + + if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + + const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); + + if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); + + // if payment needed, respond with checkout url + if (IS_TEAM_BILLING_ENABLED) { + const checkoutSession = await purchaseTeamSubscription({ + teamId: prevTeam.id, + seats: prevTeam.members.length, + userId: ctx.user.id, + }); + if (!checkoutSession.url) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed retrieving a checkout session URL.", + }); + return { url: checkoutSession.url, message: "Payment required to publish team" }; + } + + if (!metadata.data?.requestedSlug) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); + } + + const { requestedSlug, ...newMetadata } = metadata.data; + let updatedTeam: Awaited>; + + try { + updatedTeam = await prisma.team.update({ + where: { id }, + data: { + slug: requestedSlug, + metadata: { ...newMetadata }, + }, + }); + } catch (error) { + const { message } = getRequestedSlugError(error, requestedSlug); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + } + + // Sync Services: Close.com + closeComUpdateTeam(prevTeam, updatedTeam); + + return { + url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, + message: "Team published successfully", + }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/publish.schema.ts b/packages/trpc/server/routers/viewer/teams/publish.schema.ts new file mode 100644 index 0000000000..4038cb7fc1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/publish.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZPublishInputSchema = z.object({ + teamId: z.number(), +}); + +export type TPublishInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts new file mode 100644 index 0000000000..476fc3e4ce --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -0,0 +1,51 @@ +import type { PrismaClient } from "@prisma/client"; + +import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TRemoveMemberInputSchema } from "./removeMember.schema"; + +type RemoveMemberOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TRemoveMemberInputSchema; +}; + +export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => { + const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId); + if (!isAdmin && ctx.user.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); + // Only a team owner can remove another team owner. + if ((await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user.id, input.teamId))) + throw new TRPCError({ code: "UNAUTHORIZED" }); + if (ctx.user.id === input.memberId && isAdmin) + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can not remove yourself from a team you own.", + }); + + const membership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: input.memberId, teamId: input.teamId }, + }, + include: { + user: true, + }, + }); + + // Deleted managed event types from this team from this member + await ctx.prisma.eventType.deleteMany({ + where: { parent: { teamId: input.teamId }, userId: membership.userId }, + }); + + // Sync Services + closeComDeleteTeamMembership(membership.user); + if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); +}; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts new file mode 100644 index 0000000000..38ac1843c2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZRemoveMemberInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), +}); + +export type TRemoveMemberInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts new file mode 100644 index 0000000000..2de6432649 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -0,0 +1,82 @@ +import type { Prisma } from "@prisma/client"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; +import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TUpdateInputSchema } from "./update.schema"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (input.slug) { + const userConflict = await prisma.team.findMany({ + where: { + slug: input.slug, + }, + }); + if (userConflict.some((t) => t.id !== input.id)) return; + } + + const prevTeam = await prisma.team.findFirst({ + where: { + id: input.id, + }, + }); + + if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + + const data: Prisma.TeamUpdateArgs["data"] = { + name: input.name, + logo: input.logo, + bio: input.bio, + hideBranding: input.hideBranding, + hideBookATeamMember: input.hideBookATeamMember, + brandColor: input.brandColor, + darkBrandColor: input.darkBrandColor, + theme: input.theme, + }; + + if ( + input.slug && + IS_TEAM_BILLING_ENABLED && + /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ + !prevTeam.slug + ) { + // Save it on the metadata so we can use it later + data.metadata = { + requestedSlug: input.slug, + }; + } else { + data.slug = input.slug; + + // If we save slug, we don't need the requestedSlug anymore + const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata); + if (metadataParse.success) { + const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; + data.metadata = { + ...cleanMetadata, + }; + } + } + + const updatedTeam = await prisma.team.update({ + where: { id: input.id }, + data, + }); + + // Sync Services: Close.com + if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); +}; diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts new file mode 100644 index 0000000000..3a35131461 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const ZUpdateInputSchema = z.object({ + id: z.number(), + bio: z.string().optional(), + name: z.string().optional(), + logo: z.string().optional(), + slug: z.string().optional(), + hideBranding: z.boolean().optional(), + hideBookATeamMember: z.boolean().optional(), + brandColor: z.string().optional(), + darkBrandColor: z.string().optional(), + theme: z.string().optional().nullable(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts new file mode 100644 index 0000000000..9a1b1cac9d --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts @@ -0,0 +1,34 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateMembershipInputSchema } from "./updateMembership.schema"; + +type UpdateMembershipOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateMembershipInputSchema; +}; + +export const updateMembershipHandler = async ({ ctx, input }: UpdateMembershipOptions) => { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot edit memberships that are not your own.", + }); + } + + return await prisma.membership.update({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + data: { + disableImpersonation: input.disableImpersonation, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts b/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts new file mode 100644 index 0000000000..702aba5fcd --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/updateMembership.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZUpdateMembershipInputSchema = z.object({ + teamId: z.number(), + memberId: z.number(), + disableImpersonation: z.boolean(), +}); + +export type TUpdateMembershipInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/util.ts b/packages/trpc/server/routers/viewer/teams/util.ts new file mode 100644 index 0000000000..4d99c35d1f --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/util.ts @@ -0,0 +1 @@ +export const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); diff --git a/packages/trpc/server/routers/viewer/webhook.tsx b/packages/trpc/server/routers/viewer/webhook.tsx deleted file mode 100644 index b06e1344a5..0000000000 --- a/packages/trpc/server/routers/viewer/webhook.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { v4 } from "uuid"; -import { z } from "zod"; - -import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { getErrorFromUnknown } from "@calcom/lib/errors"; -import { getTranslation } from "@calcom/lib/server/i18n"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; - -// Common data for all endpoints under webhook -const webhookIdAndEventTypeIdSchema = z.object({ - // Webhook ID - id: z.string().optional(), - // Event type ID - eventTypeId: z.number().optional(), -}); - -const webhookProcedure = authedProcedure - .input(webhookIdAndEventTypeIdSchema.optional()) - .use(async ({ ctx, input, next }) => { - // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input - if (!input) return next(); - const { eventTypeId, id } = input; - - // A webhook is either linked to Event Type or to a user. - if (eventTypeId) { - const team = await ctx.prisma.team.findFirst({ - where: { - eventTypes: { - some: { - id: eventTypeId, - }, - }, - }, - include: { - members: true, - }, - }); - - // Team should be available and the user should be a member of the team - if (!team?.members.some((membership) => membership.userId === ctx.user.id)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } else if (id) { - const authorizedHook = await ctx.prisma.webhook.findFirst({ - where: { - id: id, - userId: ctx.user.id, - }, - }); - if (!authorizedHook) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } - return next(); - }); - -export const webhookRouter = router({ - list: webhookProcedure - .input( - z - .object({ - appId: z.string().optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - const where: Prisma.WebhookWhereInput = { - /* Don't mixup zapier webhooks with normal ones */ - AND: [{ appId: !input?.appId ? null : input.appId }], - }; - if (Array.isArray(where.AND)) { - if (input?.eventTypeId) { - where.AND?.push({ eventTypeId: input.eventTypeId }); - } else { - where.AND?.push({ userId: ctx.user.id }); - } - } - - return await ctx.prisma.webhook.findMany({ - where, - }); - }), - get: webhookProcedure - .input( - z.object({ - webhookId: z.string().optional(), - }) - ) - .query(async ({ ctx, input }) => { - return await ctx.prisma.webhook.findUniqueOrThrow({ - where: { - id: input.webhookId, - }, - select: { - id: true, - subscriberUrl: true, - payloadTemplate: true, - active: true, - eventTriggers: true, - secret: true, - }, - }); - }), - create: webhookProcedure - .input( - z.object({ - subscriberUrl: z.string().url(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), - active: z.boolean(), - payloadTemplate: z.string().nullable(), - eventTypeId: z.number().optional(), - appId: z.string().optional().nullable(), - secret: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - if (input.eventTypeId) { - return await ctx.prisma.webhook.create({ - data: { - id: v4(), - ...input, - }, - }); - } - - return await ctx.prisma.webhook.create({ - data: { - id: v4(), - userId: ctx.user.id, - ...input, - }, - }); - }), - edit: webhookProcedure - .input( - z.object({ - id: z.string(), - subscriberUrl: z.string().url().optional(), - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), - active: z.boolean().optional(), - payloadTemplate: z.string().nullable(), - eventTypeId: z.number().optional(), - appId: z.string().optional().nullable(), - secret: z.string().optional().nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ...data } = input; - const webhook = input.eventTypeId - ? await ctx.prisma.webhook.findFirst({ - where: { - eventTypeId: input.eventTypeId, - id, - }, - }) - : await ctx.prisma.webhook.findFirst({ - where: { - userId: ctx.user.id, - id, - }, - }); - if (!webhook) { - // user does not own this webhook - // team event doesn't own this webhook - return null; - } - return await ctx.prisma.webhook.update({ - where: { - id, - }, - data, - }); - }), - delete: webhookProcedure - .input( - z.object({ - id: z.string(), - eventTypeId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - input.eventTypeId - ? await ctx.prisma.eventType.update({ - where: { - id: input.eventTypeId, - }, - data: { - webhooks: { - delete: { - id, - }, - }, - }, - }) - : await ctx.prisma.user.update({ - where: { - id: ctx.user.id, - }, - data: { - webhooks: { - delete: { - id, - }, - }, - }, - }); - return { - id, - }; - }), - testTrigger: webhookProcedure - .input( - z.object({ - url: z.string().url(), - type: z.string(), - payloadTemplate: z.string().optional().nullable(), - }) - ) - .mutation(async ({ input }) => { - const { url, type, payloadTemplate = null } = input; - const translation = await getTranslation("en", "common"); - const language = { - locale: "en", - translate: translation, - }; - - const data = { - type: "Test", - title: "Test trigger event", - description: "", - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - attendees: [ - { - email: "jdoe@example.com", - name: "John Doe", - timeZone: "Europe/London", - language, - }, - ], - organizer: { - name: "Cal", - email: "no-reply@cal.com", - timeZone: "Europe/London", - language, - }, - }; - - try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; - return await sendPayload(null, type, new Date().toISOString(), webhook, data); - } catch (_err) { - const error = getErrorFromUnknown(_err); - return { - ok: false, - status: 500, - message: error.message, - }; - } - }), -}); diff --git a/packages/trpc/server/routers/viewer/webhook/_router.tsx b/packages/trpc/server/routers/viewer/webhook/_router.tsx new file mode 100644 index 0000000000..e4f1bc844a --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/_router.tsx @@ -0,0 +1,119 @@ +import { router } from "../../../trpc"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZEditInputSchema } from "./edit.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZListInputSchema } from "./list.schema"; +import { ZTestTriggerInputSchema } from "./testTrigger.schema"; +import { webhookProcedure } from "./util"; + +type WebhookRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + edit?: typeof import("./edit.handler").editHandler; + delete?: typeof import("./delete.handler").deleteHandler; + testTrigger?: typeof import("./testTrigger.handler").testTriggerHandler; +}; + +const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {}; + +export const webhookRouter = router({ + list: webhookProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + input, + }); + }), + + get: webhookProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: webhookProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + edit: webhookProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.edit) { + UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.edit) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.edit({ + ctx, + input, + }); + }), + + delete: webhookProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + testTrigger: webhookProcedure.input(ZTestTriggerInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.testTrigger) { + UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then( + (mod) => mod.testTriggerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.testTrigger) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.testTrigger({ + ctx, + input, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts new file mode 100644 index 0000000000..498e032218 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -0,0 +1,32 @@ +import { v4 } from "uuid"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + if (input.eventTypeId) { + return await prisma.webhook.create({ + data: { + id: v4(), + ...input, + }, + }); + } + + return await prisma.webhook.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...input, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/create.schema.ts b/packages/trpc/server/routers/viewer/webhook/create.schema.ts new file mode 100644 index 0000000000..2bef2f8ab2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/create.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({ + subscriberUrl: z.string().url(), + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), + active: z.boolean(), + payloadTemplate: z.string().nullable(), + eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts new file mode 100644 index 0000000000..601f619a30 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts @@ -0,0 +1,44 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + input.eventTypeId + ? await prisma.eventType.update({ + where: { + id: input.eventTypeId, + }, + data: { + webhooks: { + delete: { + id, + }, + }, + }, + }) + : await prisma.user.update({ + where: { + id: ctx.user.id, + }, + data: { + webhooks: { + delete: { + id, + }, + }, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/webhook/delete.schema.ts b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts new file mode 100644 index 0000000000..f2f04a402f --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({ + id: z.string(), + eventTypeId: z.number().optional(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts new file mode 100644 index 0000000000..9e1f2f447f --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts @@ -0,0 +1,39 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TEditInputSchema } from "./edit.schema"; + +type EditOptions = { + ctx: { + user: NonNullable; + }; + input: TEditInputSchema; +}; + +export const editHandler = async ({ ctx, input }: EditOptions) => { + const { id, ...data } = input; + const webhook = input.eventTypeId + ? await prisma.webhook.findFirst({ + where: { + eventTypeId: input.eventTypeId, + id, + }, + }) + : await prisma.webhook.findFirst({ + where: { + userId: ctx.user.id, + id, + }, + }); + if (!webhook) { + // user does not own this webhook + // team event doesn't own this webhook + return null; + } + return await prisma.webhook.update({ + where: { + id, + }, + data, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts new file mode 100644 index 0000000000..183df2f74b --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZEditInputSchema = webhookIdAndEventTypeIdSchema.extend({ + id: z.string(), + subscriberUrl: z.string().url().optional(), + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), + active: z.boolean().optional(), + payloadTemplate: z.string().nullable(), + eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), +}); + +export type TEditInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/get.handler.ts b/packages/trpc/server/routers/viewer/webhook/get.handler.ts new file mode 100644 index 0000000000..3050e94eca --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/get.handler.ts @@ -0,0 +1,27 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TGetInputSchema } from "./get.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => { + return await prisma.webhook.findUniqueOrThrow({ + where: { + id: input.webhookId, + }, + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/get.schema.ts b/packages/trpc/server/routers/viewer/webhook/get.schema.ts new file mode 100644 index 0000000000..6e97404ca3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/get.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZGetInputSchema = webhookIdAndEventTypeIdSchema.extend({ + webhookId: z.string().optional(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts new file mode 100644 index 0000000000..3eddf5ffec --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts @@ -0,0 +1,31 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListInputSchema } from "./list.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TListInputSchema; +}; + +export const listHandler = async ({ ctx, input }: ListOptions) => { + const where: Prisma.WebhookWhereInput = { + /* Don't mixup zapier webhooks with normal ones */ + AND: [{ appId: !input?.appId ? null : input.appId }], + }; + if (Array.isArray(where.AND)) { + if (input?.eventTypeId) { + where.AND?.push({ eventTypeId: input.eventTypeId }); + } else { + where.AND?.push({ userId: ctx.user.id }); + } + } + + return await prisma.webhook.findMany({ + where, + }); +}; diff --git a/packages/trpc/server/routers/viewer/webhook/list.schema.ts b/packages/trpc/server/routers/viewer/webhook/list.schema.ts new file mode 100644 index 0000000000..7362b4f566 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/list.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZListInputSchema = webhookIdAndEventTypeIdSchema + .extend({ + appId: z.string().optional(), + }) + .optional(); + +export type TListInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts new file mode 100644 index 0000000000..7ee9f4bbb9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -0,0 +1,53 @@ +import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { getTranslation } from "@calcom/lib/server/i18n"; + +import type { TTestTriggerInputSchema } from "./testTrigger.schema"; + +type TestTriggerOptions = { + ctx: Record; + input: TTestTriggerInputSchema; +}; + +export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { + const { url, type, payloadTemplate = null } = input; + const translation = await getTranslation("en", "common"); + const language = { + locale: "en", + translate: translation, + }; + + const data = { + type: "Test", + title: "Test trigger event", + description: "", + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + attendees: [ + { + email: "jdoe@example.com", + name: "John Doe", + timeZone: "Europe/London", + language, + }, + ], + organizer: { + name: "Cal", + email: "no-reply@cal.com", + timeZone: "Europe/London", + language, + }, + }; + + try { + const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; + return await sendPayload(null, type, new Date().toISOString(), webhook, data); + } catch (_err) { + const error = getErrorFromUnknown(_err); + return { + ok: false, + status: 500, + message: error.message, + }; + } +}; diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts new file mode 100644 index 0000000000..53f92f7e88 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const ZTestTriggerInputSchema = webhookIdAndEventTypeIdSchema.extend({ + url: z.string().url(), + type: z.string(), + payloadTemplate: z.string().optional().nullable(), +}); + +export type TTestTriggerInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/types.ts b/packages/trpc/server/routers/viewer/webhook/types.ts new file mode 100644 index 0000000000..8a57b596b0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +// Common data for all endpoints under webhook +export const webhookIdAndEventTypeIdSchema = z.object({ + // Webhook ID + id: z.string().optional(), + // Event type ID + eventTypeId: z.number().optional(), +}); diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts new file mode 100644 index 0000000000..5e1861dafa --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/util.ts @@ -0,0 +1,50 @@ +import { prisma } from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { authedProcedure } from "../../../trpc"; +import { webhookIdAndEventTypeIdSchema } from "./types"; + +export const webhookProcedure = authedProcedure + .input(webhookIdAndEventTypeIdSchema.optional()) + .use(async ({ ctx, input, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + if (!input) return next(); + const { eventTypeId, id } = input; + + // A webhook is either linked to Event Type or to a user. + if (eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === ctx.user.id)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } else if (id) { + const authorizedHook = await prisma.webhook.findFirst({ + where: { + id: id, + userId: ctx.user.id, + }, + }); + if (!authorizedHook) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + return next(); + }); diff --git a/packages/trpc/server/routers/viewer/workflows.tsx b/packages/trpc/server/routers/viewer/workflows.tsx deleted file mode 100644 index 05489f2be8..0000000000 --- a/packages/trpc/server/routers/viewer/workflows.tsx +++ /dev/null @@ -1,1580 +0,0 @@ -import type { Workflow, Prisma } from "@prisma/client"; -import { - WorkflowTemplates, - WorkflowActions, - WorkflowTriggerEvents, - BookingStatus, - WorkflowMethods, - TimeUnit, - MembershipRole, -} from "@prisma/client"; -import { z } from "zod"; - -import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; -import { - SMS_REMINDER_NUMBER_FIELD, - getSmsReminderNumberField, - getSmsReminderNumberSource, -} from "@calcom/features/bookings/lib/getBookingFields"; -import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; -import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; -import { - WORKFLOW_TEMPLATES, - WORKFLOW_TRIGGER_EVENTS, - WORKFLOW_ACTIONS, - TIME_UNIT, -} from "@calcom/features/ee/workflows/lib/constants"; -import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions"; -import { - deleteScheduledEmailReminder, - scheduleEmailReminder, -} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; -import { - deleteScheduledSMSReminder, - scheduleSMSReminder, -} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; -import { - verifyPhoneNumber, - sendVerificationCode, -} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; -import { upsertBookingField, removeBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager"; -import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants"; -import { SENDER_NAME } from "@calcom/lib/constants"; -import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; -// import { getErrorFromUnknown } from "@calcom/lib/errors"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import type PrismaType from "@calcom/prisma"; -import type { WorkflowStep } from "@calcom/prisma/client"; - -import { TRPCError } from "@trpc/server"; - -import { router, authedProcedure } from "../../trpc"; -import { viewerTeamsRouter } from "./teams"; - -function getSender( - step: Pick & { senderName: string | null | undefined } -) { - return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; -} - -async function isAuthorized( - workflow: Pick | null, - prisma: typeof PrismaType, - currentUserId: number, - readOnly?: boolean -) { - if (!workflow) { - return false; - } - - if (!readOnly) { - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - }, - }, - }, - }, - ], - }, - }); - if (userWorkflow) return true; - } - - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }, - ], - }, - }); - - if (userWorkflow) return true; - - return false; -} - -export const workflowsRouter = router({ - list: authedProcedure - .input( - z - .object({ - teamId: z.number().optional(), - userId: z.number().optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - if (input && input.teamId) { - const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ - where: { - team: { - id: input.teamId, - members: { - some: { - userId: ctx.user.id, - accepted: true, - }, - }, - }, - }, - include: { - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - }, - orderBy: { - id: "asc", - }, - }); - const workflowsWithReadOnly = workflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - return { ...workflow, readOnly }; - }); - - return { workflows: workflowsWithReadOnly }; - } - - if (input && input.userId) { - const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({ - where: { - userId: ctx.user.id, - }, - include: { - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - }, - orderBy: { - id: "asc", - }, - }); - - return { workflows }; - } - - const workflows = await ctx.prisma.workflow.findMany({ - where: { - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - accepted: true, - }, - }, - }, - }, - ], - }, - include: { - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - }, - }, - }, - }, - steps: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - }, - orderBy: { - id: "asc", - }, - }); - - const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => { - const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER - ); - - return { readOnly, ...workflow }; - }); - - return { workflows: workflowsWithReadOnly }; - }), - get: authedProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .query(async ({ ctx, input }) => { - const workflow = await ctx.prisma.workflow.findFirst({ - where: { - id: input.id, - }, - select: { - id: true, - name: true, - userId: true, - teamId: true, - team: { - select: { - id: true, - slug: true, - members: true, - }, - }, - time: true, - timeUnit: true, - activeOn: { - select: { - eventType: true, - }, - }, - trigger: true, - steps: { - orderBy: { - stepNumber: "asc", - }, - }, - }, - }); - - const isUserAuthorized = await isAuthorized(workflow, ctx.prisma, ctx.user.id); - - if (!isUserAuthorized) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - - return workflow; - }), - create: authedProcedure - .input( - z.object({ - teamId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { teamId } = input; - - const userId = ctx.user.id; - - if (teamId) { - const team = await ctx.prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId: ctx.user.id, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }); - - if (!team) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); - } - } - - try { - const workflow: Workflow = await ctx.prisma.workflow.create({ - data: { - name: "", - trigger: WorkflowTriggerEvents.BEFORE_EVENT, - time: 24, - timeUnit: TimeUnit.HOUR, - userId, - teamId, - }, - }); - - await ctx.prisma.workflowStep.create({ - data: { - stepNumber: 1, - action: WorkflowActions.EMAIL_ATTENDEE, - template: WorkflowTemplates.REMINDER, - reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody, - emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject, - workflowId: workflow.id, - sender: SENDER_NAME, - numberVerificationPending: false, - }, - }); - return { workflow }; - } catch (e) { - throw e; - } - }), - delete: authedProcedure - .input( - z.object({ - id: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const workflowToDelete = await ctx.prisma.workflow.findFirst({ - where: { - id, - }, - include: { - activeOn: true, - }, - }); - - const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.prisma, ctx.user.id, true); - - if (!isUserAuthorized || !workflowToDelete) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const scheduledReminders = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStep: { - workflowId: id, - }, - scheduled: true, - NOT: { - referenceId: null, - }, - }, - }); - - //cancel workflow reminders of deleted workflow - scheduledReminders.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - for (const activeOn of workflowToDelete.activeOn) { - await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId }); - } - - await ctx.prisma.workflow.deleteMany({ - where: { - id, - }, - }); - - return { - id, - }; - }), - update: authedProcedure - .input( - z.object({ - id: z.number(), - name: z.string(), - activeOn: z.number().array(), - steps: z - .object({ - id: z.number(), - stepNumber: z.number(), - action: z.enum(WORKFLOW_ACTIONS), - workflowId: z.number(), - sendTo: z.string().optional().nullable(), - reminderBody: z.string().optional().nullable(), - emailSubject: z.string().optional().nullable(), - template: z.enum(WORKFLOW_TEMPLATES), - numberRequired: z.boolean().nullable(), - sender: z.string().optional().nullable(), - senderName: z.string().optional().nullable(), - }) - .array(), - trigger: z.enum(WORKFLOW_TRIGGER_EVENTS), - time: z.number().nullable(), - timeUnit: z.enum(TIME_UNIT).nullable(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { id, name, activeOn, steps, trigger, time, timeUnit } = input; - - const userWorkflow = await ctx.prisma.workflow.findUnique({ - where: { - id, - }, - select: { - id: true, - userId: true, - teamId: true, - user: { - select: { - teams: true, - }, - }, - steps: true, - activeOn: true, - }, - }); - - const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); - - if (!isUserAuthorized || !userWorkflow) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (steps.find((step) => step.workflowId != id)) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ - where: { - workflowId: id, - }, - select: { - eventTypeId: true, - }, - }); - - const newActiveEventTypes = activeOn.filter((eventType) => { - if ( - !oldActiveOnEventTypes || - !oldActiveOnEventTypes - .map((oldEventType) => { - return oldEventType.eventTypeId; - }) - .includes(eventType) - ) { - return eventType; - } - }); - - //check if new event types belong to user or team - for (const newEventTypeId of newActiveEventTypes) { - const newEventType = await ctx.prisma.eventType.findFirst({ - where: { - id: newEventTypeId, - }, - include: { - users: true, - team: { - include: { - members: true, - }, - }, - }, - }); - - if (newEventType) { - if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if ( - !userWorkflow.teamId && - userWorkflow.userId && - newEventType.userId !== userWorkflow.userId && - !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - } - } - - //remove all scheduled Email and SMS reminders for eventTypes that are not active any more - const removedEventTypes = oldActiveOnEventTypes - .map((eventType) => { - return eventType.eventTypeId; - }) - .filter((eventType) => { - if (!activeOn.includes(eventType)) { - return eventType; - } - }); - - const remindersToDeletePromise: Prisma.PrismaPromise< - { - id: number; - referenceId: string | null; - method: string; - scheduled: boolean; - }[] - >[] = []; - - removedEventTypes.forEach((eventTypeId) => { - const reminderToDelete = ctx.prisma.workflowReminder.findMany({ - where: { - booking: { - eventTypeId: eventTypeId, - userId: ctx.user.id, - }, - workflowStepId: { - in: userWorkflow.steps.map((step) => { - return step.id; - }), - }, - }, - select: { - id: true, - referenceId: true, - method: true, - scheduled: true, - }, - }); - - remindersToDeletePromise.push(reminderToDelete); - }); - - const remindersToDelete = await Promise.all(remindersToDeletePromise); - - //cancel workflow reminders for all bookings from event types that got disabled - remindersToDelete.flat().forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - - //update active on & reminders for new eventTypes - await ctx.prisma.workflowsOnEventTypes.deleteMany({ - where: { - workflowId: id, - }, - }); - - let newEventTypes: number[] = []; - if (activeOn.length) { - if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { - newEventTypes = newActiveEventTypes; - } - if (newEventTypes.length > 0) { - //create reminders for all bookings with newEventTypes - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { in: newEventTypes }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - - steps.forEach(async (step) => { - if (step.action !== WorkflowActions.SMS_ATTENDEE) { - //as we do not have attendees phone number (user is notified about that when setting this action) - bookingsForReminders.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - language: { locale: booking.user.locale || "" }, - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - if ( - step.action === WorkflowActions.EMAIL_HOST || - step.action === WorkflowActions.EMAIL_ATTENDEE /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (step.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - step.action, - { - time, - timeUnit, - }, - sendTo, - step.emailSubject || "", - step.reminderBody || "", - step.id, - step.template, - step.senderName || SENDER_NAME - ); - } else if (step.action === WorkflowActions.SMS_NUMBER) { - await scheduleSMSReminder( - bookingInfo, - step.sendTo || "", - trigger, - step.action, - { - time, - timeUnit, - }, - step.reminderBody || "", - step.id, - step.template, - step.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - }); - } - //create all workflow - eventtypes relationships - activeOn.forEach(async (eventTypeId) => { - await ctx.prisma.workflowsOnEventTypes.createMany({ - data: { - workflowId: id, - eventTypeId, - }, - }); - }); - } - - userWorkflow.steps.map(async (oldStep) => { - const newStep = steps.filter((s) => s.id === oldStep.id)[0]; - const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ - where: { - workflowStepId: oldStep.id, - }, - include: { - booking: true, - }, - }); - - //step was deleted - if (!newStep) { - // cancel all workflow reminders from deleted steps - if (remindersFromStep.length > 0) { - remindersFromStep.forEach((reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - } - await ctx.prisma.workflowStep.delete({ - where: { - id: oldStep.id, - }, - }); - - //step was edited - } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { - if ( - !userWorkflow.teamId && - !userWorkflow.user?.teams.length && - !isSMSAction(oldStep.action) && - isSMSAction(newStep.action) - ) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - await ctx.prisma.workflowStep.update({ - where: { - id: oldStep.id, - }, - data: { - action: newStep.action, - sendTo: - newStep.action === WorkflowActions.SMS_NUMBER /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ - ? newStep.sendTo - : null, - stepNumber: newStep.stepNumber, - workflowId: newStep.workflowId, - reminderBody: newStep.reminderBody, - emailSubject: newStep.emailSubject, - template: newStep.template, - numberRequired: newStep.numberRequired, - sender: getSender({ - action: newStep.action, - sender: newStep.sender || null, - senderName: newStep.senderName, - }), - numberVerificationPending: false, - }, - }); - //cancel all reminders of step and create new ones (not for newEventTypes) - const remindersToUpdate = remindersFromStep.filter((reminder) => { - if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) { - return reminder; - } - }); - - //cancel all workflow reminders from steps that were edited - remindersToUpdate.forEach(async (reminder) => { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.id, reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.id, reminder.referenceId); - } - }); - const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => { - if (!newEventTypes.includes(eventTypeId)) { - return eventTypeId; - } - }); - if ( - eventTypesToUpdateReminders && - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) - ) { - const bookingsOfEventTypes = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { - in: eventTypesToUpdateReminders, - }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - bookingsOfEventTypes.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - language: { locale: booking.user.locale || "" }, - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - if ( - newStep.action === WorkflowActions.EMAIL_HOST || - newStep.action === WorkflowActions.EMAIL_ATTENDEE /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (newStep.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = newStep.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - newStep.action, - { - time, - timeUnit, - }, - sendTo, - newStep.emailSubject || "", - newStep.reminderBody || "", - newStep.id, - newStep.template, - newStep.senderName || SENDER_NAME - ); - } else if (newStep.action === WorkflowActions.SMS_NUMBER) { - await scheduleSMSReminder( - bookingInfo, - newStep.sendTo || "", - trigger, - newStep.action, - { - time, - timeUnit, - }, - newStep.reminderBody || "", - newStep.id || 0, - newStep.template, - newStep.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - } - }); - //added steps - const addedSteps = steps.map((s) => { - if (s.id <= 0) { - if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - const { id: _stepId, ...stepToAdd } = s; - return stepToAdd; - } - }); - - if (addedSteps) { - const eventTypesToCreateReminders = activeOn.map((activeEventType) => { - if (activeEventType && !newEventTypes.includes(activeEventType)) { - return activeEventType; - } - }); - addedSteps.forEach(async (step) => { - if (step) { - const { senderName, ...newStep } = step; - newStep.sender = getSender({ - action: newStep.action, - sender: newStep.sender || null, - senderName: senderName, - }); - const createdStep = await ctx.prisma.workflowStep.create({ - data: { ...newStep, numberVerificationPending: false }, - }); - if ( - (trigger === WorkflowTriggerEvents.BEFORE_EVENT || - trigger === WorkflowTriggerEvents.AFTER_EVENT) && - eventTypesToCreateReminders && - step.action !== WorkflowActions.SMS_ATTENDEE - ) { - const bookingsForReminders = await ctx.prisma.booking.findMany({ - where: { - eventTypeId: { in: eventTypesToCreateReminders as number[] }, - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - include: { - attendees: true, - eventType: true, - user: true, - }, - }); - bookingsForReminders.forEach(async (booking) => { - const bookingInfo = { - uid: booking.uid, - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || "" }, - }; - }), - organizer: booking.user - ? { - name: booking.user.name || "", - email: booking.user.email, - timeZone: booking.user.timeZone, - language: { locale: booking.user.locale || "" }, - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || "" }, - eventType: { - slug: booking.eventType?.slug, - }, - }; - - if ( - step.action === WorkflowActions.EMAIL_ATTENDEE || - step.action === WorkflowActions.EMAIL_HOST /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ - ) { - let sendTo = ""; - - switch (step.action) { - case WorkflowActions.EMAIL_HOST: - sendTo = bookingInfo.organizer?.email; - break; - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees[0].email; - break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ - } - - await scheduleEmailReminder( - bookingInfo, - trigger, - step.action, - { - time, - timeUnit, - }, - sendTo, - step.emailSubject || "", - step.reminderBody || "", - createdStep.id, - step.template, - step.senderName || SENDER_NAME - ); - } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { - await scheduleSMSReminder( - bookingInfo, - step.sendTo, - trigger, - step.action, - { - time, - timeUnit, - }, - step.reminderBody || "", - createdStep.id, - step.template, - step.sender || SENDER_ID, - user.id, - userWorkflow.teamId - ); - } - }); - } - } - }); - } - - //update trigger, name, time, timeUnit - await ctx.prisma.workflow.update({ - where: { - id, - }, - data: { - name, - trigger, - time, - timeUnit, - }, - }); - - const workflow = await ctx.prisma.workflow.findFirst({ - where: { - id, - }, - include: { - activeOn: { - select: { - eventType: true, - }, - }, - team: { - select: { - id: true, - slug: true, - members: true, - }, - }, - steps: { - orderBy: { - stepNumber: "asc", - }, - }, - }, - }); - - // Remove or add booking field for sms reminder number - const smsReminderNumberNeeded = - activeOn.length && steps.some((step) => step.action === WorkflowActions.SMS_ATTENDEE); - - for (const removedEventType of removedEventTypes) { - await removeSmsReminderFieldForBooking({ - workflowId: id, - eventTypeId: removedEventType, - }); - } - - for (const eventTypeId of activeOn) { - if (smsReminderNumberNeeded) { - await upsertSmsReminderFieldForBooking({ - workflowId: id, - isSmsReminderNumberRequired: steps.some( - (s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired - ), - eventTypeId, - }); - } else { - await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId }); - } - } - - return { - workflow, - }; - }), - /* testAction: authedRateLimitedProcedure({ intervalInMs: 10000, limit: 3 }) - .input( - z.object({ - step: z.object({ - id: z.number(), - stepNumber: z.number(), - action: z.enum(WORKFLOW_ACTIONS), - workflowId: z.number(), - sendTo: z.string().optional().nullable(), - reminderBody: z.string().optional().nullable(), - emailSubject: z.string().optional().nullable(), - template: z.enum(WORKFLOW_TEMPLATES), - numberRequired: z.boolean().nullable(), - sender: z.string().optional().nullable(), - }), - emailSubject: z.string(), - reminderBody: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user } = ctx; - const { step, emailSubject, reminderBody } = input; - const { action, template, sendTo, sender } = step; - - const senderID = sender || SENDER_ID; - - if (action === WorkflowActions.SMS_NUMBER) { - if (!sendTo) throw new TRPCError({ code: "BAD_REQUEST", message: "Missing sendTo" }); - const verifiedNumbers = await ctx.prisma.verifiedNumber.findFirst({ - where: { - userId: ctx.user.id, - phoneNumber: sendTo, - }, - }); - if (!verifiedNumbers) - throw new TRPCError({ code: "UNAUTHORIZED", message: "Phone number is not verified" }); - } - - try { - const userWorkflow = await ctx.prisma.workflow.findUnique({ - where: { - id: step.workflowId, - }, - select: { - userId: true, - steps: true, - }, - }); - - if (!userWorkflow || userWorkflow.userId !== user.id) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (isSMSAction(step.action) /*|| step.action === WorkflowActions.EMAIL_ADDRESS*/ /*) { -const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId: user.id } })) > 0; -if (!hasTeamPlan) { -throw new TRPCError({ code: "UNAUTHORIZED", message: "Team plan needed" }); -} -} - -const booking = await ctx.prisma.booking.findFirst({ -orderBy: { -createdAt: "desc", -}, -where: { -userId: ctx.user.id, -}, -include: { -attendees: true, -user: true, -}, -}); - -let evt: BookingInfo; -if (booking) { -evt = { -uid: booking?.uid, -attendees: -booking?.attendees.map((attendee) => { -return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone }; -}) || [], -organizer: { -language: { -locale: booking?.user?.locale || "", -}, -name: booking?.user?.name || "", -email: booking?.user?.email || "", -timeZone: booking?.user?.timeZone || "", -}, -startTime: booking?.startTime.toISOString() || "", -endTime: booking?.endTime.toISOString() || "", -title: booking?.title || "", -location: booking?.location || null, -additionalNotes: booking?.description || null, -customInputs: booking?.customInputs, -}; -} else { -//if no booking exists create an example booking -evt = { -attendees: [{ name: "John Doe", email: "john.doe@example.com", timeZone: "Europe/London" }], -organizer: { -language: { -locale: ctx.user.locale, -}, -name: ctx.user.name || "", -email: ctx.user.email, -timeZone: ctx.user.timeZone, -}, -startTime: dayjs().add(10, "hour").toISOString(), -endTime: dayjs().add(11, "hour").toISOString(), -title: "Example Booking", -location: "Office", -additionalNotes: "These are additional notes", -}; -} - -if ( -action === WorkflowActions.EMAIL_ATTENDEE || -action === WorkflowActions.EMAIL_HOST /*|| -action === WorkflowActions.EMAIL_ADDRESS*/ - /*) { - scheduleEmailReminder( - evt, - WorkflowTriggerEvents.NEW_EVENT, - action, - { time: null, timeUnit: null }, - ctx.user.email, - emailSubject, - reminderBody, - 0, - template - ); - return { message: "Notification sent" }; - } else if (action === WorkflowActions.SMS_NUMBER && sendTo) { - scheduleSMSReminder( - evt, - sendTo, - WorkflowTriggerEvents.NEW_EVENT, - action, - { time: null, timeUnit: null }, - reminderBody, - 0, - template, - senderID, - ctx.user.id - ); - return { message: "Notification sent" }; - } - return { - ok: false, - status: 500, - message: "Notification could not be sent", - }; - } catch (_err) { - const error = getErrorFromUnknown(_err); - return { - ok: false, - status: 500, - message: error.message, - }; - } - }), - */ - activateEventType: authedProcedure - .input( - z.object({ - eventTypeId: z.number(), - workflowId: z.number(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { eventTypeId, workflowId } = input; - - // Check that vent type belong to the user or team - const userEventType = await ctx.prisma.eventType.findFirst({ - where: { - id: eventTypeId, - OR: [ - { userId: ctx.user.id }, - { - team: { - members: { - some: { - userId: ctx.user.id, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }, - ], - }, - }); - - if (!userEventType) - throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" }); - - // Check that the workflow belongs to the user or team - const eventTypeWorkflow = await ctx.prisma.workflow.findFirst({ - where: { - id: workflowId, - OR: [ - { - userId: ctx.user.id, - }, - { - teamId: userEventType.teamId, - }, - ], - }, - include: { - steps: true, - }, - }); - - if (!eventTypeWorkflow) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Not authorized to enable/disable this workflow", - }); - - //check if event type is already active - const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({ - where: { - workflowId, - eventTypeId, - }, - }); - - if (isActive) { - await ctx.prisma.workflowsOnEventTypes.deleteMany({ - where: { - workflowId, - eventTypeId, - }, - }); - - await removeSmsReminderFieldForBooking({ - workflowId, - eventTypeId, - }); - } else { - await ctx.prisma.workflowsOnEventTypes.create({ - data: { - workflowId, - eventTypeId, - }, - }); - - if ( - eventTypeWorkflow.steps.some((step) => { - return step.action === WorkflowActions.SMS_ATTENDEE; - }) - ) { - const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => { - return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired; - }); - await upsertSmsReminderFieldForBooking({ - workflowId, - isSmsReminderNumberRequired, - eventTypeId, - }); - } - } - }), - sendVerificationCode: authedProcedure - .input( - z.object({ - phoneNumber: z.string(), - }) - ) - .mutation(async ({ input }) => { - const { phoneNumber } = input; - return sendVerificationCode(phoneNumber); - }), - verifyPhoneNumber: authedProcedure - .input( - z.object({ - phoneNumber: z.string(), - code: z.string(), - teamId: z.number().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { phoneNumber, code, teamId } = input; - const { user } = ctx; - const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId); - return verifyStatus; - }), - getVerifiedNumbers: authedProcedure - .input( - z.object({ - teamId: z.number().optional(), - }) - ) - .query(async ({ ctx, input }) => { - const { user } = ctx; - const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({ - where: { - OR: [{ userId: user.id }, { teamId: input.teamId }], - }, - }); - - return verifiedNumbers; - }), - getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; - - const isCurrentUsernamePremium = user && user.metadata && hasKeyInMetadata(user, "isPremium"); - - let isTeamsPlan = false; - if (!isCurrentUsernamePremium) { - const { hasTeamPlan } = await viewerTeamsRouter.createCaller(ctx).hasTeamPlan(); - isTeamsPlan = !!hasTeamPlan; - } - const t = await getTranslation(ctx.user.locale, "common"); - return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); - }), - getByViewer: authedProcedure.query(async ({ ctx }) => { - const { prisma } = ctx; - - const user = await prisma.user.findUnique({ - where: { - id: ctx.user.id, - }, - select: { - id: true, - username: true, - avatar: true, - name: true, - startTime: true, - endTime: true, - bufferTime: true, - workflows: { - select: { - id: true, - name: true, - }, - }, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - members: { - select: { - userId: true, - }, - }, - workflows: { - select: { - id: true, - name: true, - }, - }, - }, - }, - }, - }, - }, - }); - - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); - } - - const userWorkflows = user.workflows; - - type WorkflowGroup = { - teamId?: number | null; - profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; - image?: string; - }; - metadata?: { - readOnly: boolean; - }; - workflows: typeof userWorkflows; - }; - - let workflowGroups: WorkflowGroup[] = []; - - workflowGroups.push({ - teamId: null, - profile: { - slug: user.username, - name: user.name, - image: user.avatar || undefined, - }, - workflows: userWorkflows, - metadata: { - readOnly: false, - }, - }); - - workflowGroups = ([] as WorkflowGroup[]).concat( - workflowGroups, - user.teams.map((membership) => ({ - teamId: membership.team.id, - profile: { - name: membership.team.name, - slug: "team/" + membership.team.slug, - image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, - }, - metadata: { - readOnly: membership.role === MembershipRole.MEMBER, - }, - workflows: membership.team.workflows, - })) - ); - - return { - workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length), - profiles: workflowGroups.map((group) => ({ - teamId: group.teamId, - ...group.profile, - ...group.metadata, - })), - }; - }), -}); - -async function upsertSmsReminderFieldForBooking({ - workflowId, - eventTypeId, - isSmsReminderNumberRequired, -}: { - workflowId: number; - isSmsReminderNumberRequired: boolean; - eventTypeId: number; -}) { - await upsertBookingField( - getSmsReminderNumberField(), - getSmsReminderNumberSource({ - workflowId, - isSmsReminderNumberRequired, - }), - eventTypeId - ); -} - -async function removeSmsReminderFieldForBooking({ - workflowId, - eventTypeId, -}: { - workflowId: number; - eventTypeId: number; -}) { - await removeBookingField( - { - name: SMS_REMINDER_NUMBER_FIELD, - }, - { - id: "" + workflowId, - type: "workflow", - }, - eventTypeId - ); -} diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx new file mode 100644 index 0000000000..a273781f8d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -0,0 +1,216 @@ +import { authedProcedure, router } from "../../../trpc"; +import { ZActivateEventTypeInputSchema } from "./activateEventType.schema"; +import { ZCreateInputSchema } from "./create.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZGetInputSchema } from "./get.schema"; +import { ZGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; +import { ZListInputSchema } from "./list.schema"; +import { ZSendVerificationCodeInputSchema } from "./sendVerificationCode.schema"; +import { ZUpdateInputSchema } from "./update.schema"; +import { ZVerifyPhoneNumberInputSchema } from "./verifyPhoneNumber.schema"; + +type WorkflowsRouterHandlerCache = { + list?: typeof import("./list.handler").listHandler; + get?: typeof import("./get.handler").getHandler; + create?: typeof import("./create.handler").createHandler; + delete?: typeof import("./delete.handler").deleteHandler; + update?: typeof import("./update.handler").updateHandler; + activateEventType?: typeof import("./activateEventType.handler").activateEventTypeHandler; + sendVerificationCode?: typeof import("./sendVerificationCode.handler").sendVerificationCodeHandler; + verifyPhoneNumber?: typeof import("./verifyPhoneNumber.handler").verifyPhoneNumberHandler; + getVerifiedNumbers?: typeof import("./getVerifiedNumbers.handler").getVerifiedNumbersHandler; + getWorkflowActionOptions?: typeof import("./getWorkflowActionOptions.handler").getWorkflowActionOptionsHandler; + getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler; +}; + +const UNSTABLE_HANDLER_CACHE: WorkflowsRouterHandlerCache = {}; + +export const workflowsRouter = router({ + list: authedProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.list) { + UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.list) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.list({ + ctx, + input, + }); + }), + + get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.get) { + UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.get) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.get({ + ctx, + input, + }); + }), + + create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.create) { + UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.create) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.create({ + ctx, + input, + }); + }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.update) { + UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.update) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.update({ + ctx, + input, + }); + }), + + activateEventType: authedProcedure.input(ZActivateEventTypeInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.activateEventType) { + UNSTABLE_HANDLER_CACHE.activateEventType = await import("./activateEventType.handler").then( + (mod) => mod.activateEventTypeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.activateEventType) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.activateEventType({ + ctx, + input, + }); + }), + + sendVerificationCode: authedProcedure + .input(ZSendVerificationCodeInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.sendVerificationCode) { + UNSTABLE_HANDLER_CACHE.sendVerificationCode = await import("./sendVerificationCode.handler").then( + (mod) => mod.sendVerificationCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.sendVerificationCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.sendVerificationCode({ + ctx, + input, + }); + }), + + verifyPhoneNumber: authedProcedure.input(ZVerifyPhoneNumberInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyPhoneNumber) { + UNSTABLE_HANDLER_CACHE.verifyPhoneNumber = await import("./verifyPhoneNumber.handler").then( + (mod) => mod.verifyPhoneNumberHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyPhoneNumber) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyPhoneNumber({ + ctx, + input, + }); + }), + + getVerifiedNumbers: authedProcedure.input(ZGetVerifiedNumbersInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getVerifiedNumbers) { + UNSTABLE_HANDLER_CACHE.getVerifiedNumbers = await import("./getVerifiedNumbers.handler").then( + (mod) => mod.getVerifiedNumbersHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getVerifiedNumbers) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getVerifiedNumbers({ + ctx, + input, + }); + }), + + getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions) { + UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions = await import( + "./getWorkflowActionOptions.handler" + ).then((mod) => mod.getWorkflowActionOptionsHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions({ + ctx, + }); + }), + + getByViewer: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then( + (mod) => mod.getByViewerHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getByViewer) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getByViewer({ + ctx, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts new file mode 100644 index 0000000000..a952cd42a2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -0,0 +1,114 @@ +import { MembershipRole, WorkflowActions } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TActivateEventTypeInputSchema } from "./activateEventType.schema"; +import { removeSmsReminderFieldForBooking, upsertSmsReminderFieldForBooking } from "./util"; + +type ActivateEventTypeOptions = { + ctx: { + user: NonNullable; + }; + input: TActivateEventTypeInputSchema; +}; + +export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventTypeOptions) => { + const { eventTypeId, workflowId } = input; + + // Check that vent type belong to the user or team + const userEventType = await prisma.eventType.findFirst({ + where: { + id: eventTypeId, + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }, + ], + }, + }); + + if (!userEventType) + throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" }); + + // Check that the workflow belongs to the user or team + const eventTypeWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflowId, + OR: [ + { + userId: ctx.user.id, + }, + { + teamId: userEventType.teamId, + }, + ], + }, + include: { + steps: true, + }, + }); + + if (!eventTypeWorkflow) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Not authorized to enable/disable this workflow", + }); + + //check if event type is already active + const isActive = await prisma.workflowsOnEventTypes.findFirst({ + where: { + workflowId, + eventTypeId, + }, + }); + + if (isActive) { + await prisma.workflowsOnEventTypes.deleteMany({ + where: { + workflowId, + eventTypeId, + }, + }); + + await removeSmsReminderFieldForBooking({ + workflowId, + eventTypeId, + }); + } else { + await prisma.workflowsOnEventTypes.create({ + data: { + workflowId, + eventTypeId, + }, + }); + + if ( + eventTypeWorkflow.steps.some((step) => { + return step.action === WorkflowActions.SMS_ATTENDEE; + }) + ) { + const isSmsReminderNumberRequired = eventTypeWorkflow.steps.some((step) => { + return step.action === WorkflowActions.SMS_ATTENDEE && step.numberRequired; + }); + await upsertSmsReminderFieldForBooking({ + workflowId, + isSmsReminderNumberRequired, + eventTypeId, + }); + } + } +}; diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts new file mode 100644 index 0000000000..78446e3b8d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZActivateEventTypeInputSchema = z.object({ + eventTypeId: z.number(), + workflowId: z.number(), +}); + +export type TActivateEventTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/create.handler.ts b/packages/trpc/server/routers/viewer/workflows/create.handler.ts new file mode 100644 index 0000000000..9a346b23f7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/create.handler.ts @@ -0,0 +1,84 @@ +import type { Workflow } from "@prisma/client"; +import { + MembershipRole, + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@prisma/client"; + +import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; +import { SENDER_NAME } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TCreateInputSchema } from "./create.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TCreateInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateOptions) => { + const { teamId } = input; + + const userId = ctx.user.id; + + if (teamId) { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + + try { + const workflow: Workflow = await prisma.workflow.create({ + data: { + name: "", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 24, + timeUnit: TimeUnit.HOUR, + userId, + teamId, + }, + }); + + await ctx.prisma.workflowStep.create({ + data: { + stepNumber: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + template: WorkflowTemplates.REMINDER, + reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody, + emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject, + workflowId: workflow.id, + sender: SENDER_NAME, + numberVerificationPending: false, + }, + }); + return { workflow }; + } catch (e) { + throw e; + } +}; diff --git a/packages/trpc/server/routers/viewer/workflows/create.schema.ts b/packages/trpc/server/routers/viewer/workflows/create.schema.ts new file mode 100644 index 0000000000..2dbc2bcbfa --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/create.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZCreateInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts new file mode 100644 index 0000000000..d1dfe978eb --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -0,0 +1,72 @@ +import { WorkflowMethods } from "@prisma/client"; + +import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; +import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TDeleteInputSchema } from "./delete.schema"; +import { isAuthorized, removeSmsReminderFieldForBooking } from "./util"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + + const workflowToDelete = await prisma.workflow.findFirst({ + where: { + id, + }, + include: { + activeOn: true, + }, + }); + + const isUserAuthorized = await isAuthorized(workflowToDelete, prisma, ctx.user.id, true); + + if (!isUserAuthorized || !workflowToDelete) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const scheduledReminders = await prisma.workflowReminder.findMany({ + where: { + workflowStep: { + workflowId: id, + }, + scheduled: true, + NOT: { + referenceId: null, + }, + }, + }); + + //cancel workflow reminders of deleted workflow + scheduledReminders.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + for (const activeOn of workflowToDelete.activeOn) { + await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId: activeOn.eventTypeId }); + } + + await prisma.workflow.deleteMany({ + where: { + id, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.schema.ts b/packages/trpc/server/routers/viewer/workflows/delete.schema.ts new file mode 100644 index 0000000000..411d5e953b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/get.handler.ts b/packages/trpc/server/routers/viewer/workflows/get.handler.ts new file mode 100644 index 0000000000..e9bdefaa7e --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/get.handler.ts @@ -0,0 +1,58 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetInputSchema } from "./get.schema"; +import { isAuthorized } from "./util"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TGetInputSchema; +}; + +export const getHandler = async ({ ctx, input }: GetOptions) => { + const workflow = await prisma.workflow.findFirst({ + where: { + id: input.id, + }, + select: { + id: true, + name: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, + time: true, + timeUnit: true, + activeOn: { + select: { + eventType: true, + }, + }, + trigger: true, + steps: { + orderBy: { + stepNumber: "asc", + }, + }, + }, + }); + + const isUserAuthorized = await isAuthorized(workflow, prisma, ctx.user.id); + + if (!isUserAuthorized) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + + return workflow; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/get.schema.ts b/packages/trpc/server/routers/viewer/workflows/get.schema.ts new file mode 100644 index 0000000000..d549577697 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetInputSchema = z.object({ + id: z.number(), +}); + +export type TGetInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts new file mode 100644 index 0000000000..0f397e7094 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getByViewer.handler.ts @@ -0,0 +1,121 @@ +import { MembershipRole } from "@prisma/client"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +type GetByViewerOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + avatar: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + workflows: { + select: { + id: true, + name: true, + }, + }, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + members: { + select: { + userId: true, + }, + }, + workflows: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + + const userWorkflows = user.workflows; + + type WorkflowGroup = { + teamId?: number | null; + profile: { + slug: (typeof user)["username"]; + name: (typeof user)["name"]; + image?: string; + }; + metadata?: { + readOnly: boolean; + }; + workflows: typeof userWorkflows; + }; + + let workflowGroups: WorkflowGroup[] = []; + + workflowGroups.push({ + teamId: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar || undefined, + }, + workflows: userWorkflows, + metadata: { + readOnly: false, + }, + }); + + workflowGroups = ([] as WorkflowGroup[]).concat( + workflowGroups, + user.teams.map((membership) => ({ + teamId: membership.team.id, + profile: { + name: membership.team.name, + slug: "team/" + membership.team.slug, + image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, + }, + metadata: { + readOnly: membership.role === MembershipRole.MEMBER, + }, + workflows: membership.team.workflows, + })) + ); + + return { + workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length), + profiles: workflowGroups.map((group) => ({ + teamId: group.teamId, + ...group.profile, + ...group.metadata, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getByViewer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts new file mode 100644 index 0000000000..406fcde53b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.handler.ts @@ -0,0 +1,22 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; + +type GetVerifiedNumbersOptions = { + ctx: { + user: NonNullable; + }; + input: TGetVerifiedNumbersInputSchema; +}; + +export const getVerifiedNumbersHandler = async ({ ctx, input }: GetVerifiedNumbersOptions) => { + const { user } = ctx; + const verifiedNumbers = await prisma.verifiedNumber.findMany({ + where: { + OR: [{ userId: user.id }, { teamId: input.teamId }], + }, + }); + + return verifiedNumbers; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts new file mode 100644 index 0000000000..032231919e --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedNumbers.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetVerifiedNumbersInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TGetVerifiedNumbersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts new file mode 100644 index 0000000000..ad12d6ddd6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts @@ -0,0 +1,29 @@ +import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler"; + +type GetWorkflowActionOptionsOptions = { + ctx: { + user: NonNullable & { + locale: string; + }; + }; +}; + +export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowActionOptionsOptions) => { + const { user } = ctx; + + const isCurrentUsernamePremium = user && user.metadata && hasKeyInMetadata(user, "isPremium"); + + let isTeamsPlan = false; + if (!isCurrentUsernamePremium) { + const { hasTeamPlan } = await hasTeamPlanHandler({ ctx }); + isTeamsPlan = !!hasTeamPlan; + } + const t = await getTranslation(ctx.user.locale, "common"); + return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan); +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/workflows/list.handler.ts b/packages/trpc/server/routers/viewer/workflows/list.handler.ts new file mode 100644 index 0000000000..8baac7f4a3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/list.handler.ts @@ -0,0 +1,152 @@ +import { MembershipRole } from "@prisma/client"; + +import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage"; +// import dayjs from "@calcom/dayjs"; +// import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TListInputSchema } from "./list.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TListInputSchema; +}; + +export const listHandler = async ({ ctx, input }: ListOptions) => { + if (input && input.teamId) { + const workflows: WorkflowType[] = await prisma.workflow.findMany({ + where: { + team: { + id: input.teamId, + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + include: { + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + }, + orderBy: { + id: "asc", + }, + }); + const workflowsWithReadOnly = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + return { ...workflow, readOnly }; + }); + + return { workflows: workflowsWithReadOnly }; + } + + if (input && input.userId) { + const workflows: WorkflowType[] = await prisma.workflow.findMany({ + where: { + userId: ctx.user.id, + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + return { workflows }; + } + + const workflows = await prisma.workflow.findMany({ + where: { + OR: [ + { userId: ctx.user.id }, + { + team: { + members: { + some: { + userId: ctx.user.id, + accepted: true, + }, + }, + }, + }, + ], + }, + include: { + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + }, + }, + }, + }, + steps: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => { + const readOnly = !!workflow.team?.members?.find( + (member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER + ); + + return { readOnly, ...workflow }; + }); + + return { workflows: workflowsWithReadOnly }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/list.schema.ts b/packages/trpc/server/routers/viewer/workflows/list.schema.ts new file mode 100644 index 0000000000..e78497d03d --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/list.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZListInputSchema = z + .object({ + teamId: z.number().optional(), + userId: z.number().optional(), + }) + .optional(); + +export type TListInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts new file mode 100644 index 0000000000..b7a96d848b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts @@ -0,0 +1,16 @@ +import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TSendVerificationCodeInputSchema } from "./sendVerificationCode.schema"; + +type SendVerificationCodeOptions = { + ctx: { + user: NonNullable; + }; + input: TSendVerificationCodeInputSchema; +}; + +export const sendVerificationCodeHandler = async ({ ctx: _ctx, input }: SendVerificationCodeOptions) => { + const { phoneNumber } = input; + return sendVerificationCode(phoneNumber); +}; diff --git a/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts new file mode 100644 index 0000000000..8def3a8b33 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZSendVerificationCodeInputSchema = z.object({ + phoneNumber: z.string(), +}); + +export type TSendVerificationCodeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts new file mode 100644 index 0000000000..cbfc2b9c18 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -0,0 +1,685 @@ +import type { Prisma } from "@prisma/client"; +import { BookingStatus, WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@prisma/client"; + +import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { + deleteScheduledEmailReminder, + scheduleEmailReminder, +} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; +import { + deleteScheduledSMSReminder, + scheduleSMSReminder, +} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; +import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateInputSchema } from "./update.schema"; +import { + getSender, + isAuthorized, + removeSmsReminderFieldForBooking, + upsertSmsReminderFieldForBooking, +} from "./util"; + +type UpdateOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TUpdateInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateOptions) => { + const { user } = ctx; + const { id, name, activeOn, steps, trigger, time, timeUnit } = input; + + const userWorkflow = await ctx.prisma.workflow.findUnique({ + where: { + id, + }, + select: { + id: true, + userId: true, + teamId: true, + user: { + select: { + teams: true, + }, + }, + steps: true, + activeOn: true, + }, + }); + + const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true); + + if (!isUserAuthorized || !userWorkflow) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if (steps.find((step) => step.workflowId != id)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({ + where: { + workflowId: id, + }, + select: { + eventTypeId: true, + }, + }); + + const newActiveEventTypes = activeOn.filter((eventType) => { + if ( + !oldActiveOnEventTypes || + !oldActiveOnEventTypes + .map((oldEventType) => { + return oldEventType.eventTypeId; + }) + .includes(eventType) + ) { + return eventType; + } + }); + + //check if new event types belong to user or team + for (const newEventTypeId of newActiveEventTypes) { + const newEventType = await ctx.prisma.eventType.findFirst({ + where: { + id: newEventTypeId, + }, + include: { + users: true, + team: { + include: { + members: true, + }, + }, + }, + }); + + if (newEventType) { + if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if ( + !userWorkflow.teamId && + userWorkflow.userId && + newEventType.userId !== userWorkflow.userId && + !newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId) + ) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + } + + //remove all scheduled Email and SMS reminders for eventTypes that are not active any more + const removedEventTypes = oldActiveOnEventTypes + .map((eventType) => { + return eventType.eventTypeId; + }) + .filter((eventType) => { + if (!activeOn.includes(eventType)) { + return eventType; + } + }); + + const remindersToDeletePromise: Prisma.PrismaPromise< + { + id: number; + referenceId: string | null; + method: string; + scheduled: boolean; + }[] + >[] = []; + + removedEventTypes.forEach((eventTypeId) => { + const reminderToDelete = ctx.prisma.workflowReminder.findMany({ + where: { + booking: { + eventTypeId: eventTypeId, + userId: ctx.user.id, + }, + workflowStepId: { + in: userWorkflow.steps.map((step) => { + return step.id; + }), + }, + }, + select: { + id: true, + referenceId: true, + method: true, + scheduled: true, + }, + }); + + remindersToDeletePromise.push(reminderToDelete); + }); + + const remindersToDelete = await Promise.all(remindersToDeletePromise); + + //cancel workflow reminders for all bookings from event types that got disabled + remindersToDelete.flat().forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + + //update active on & reminders for new eventTypes + await ctx.prisma.workflowsOnEventTypes.deleteMany({ + where: { + workflowId: id, + }, + }); + + let newEventTypes: number[] = []; + if (activeOn.length) { + if (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) { + newEventTypes = newActiveEventTypes; + } + if (newEventTypes.length > 0) { + //create reminders for all bookings with newEventTypes + const bookingsForReminders = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { in: newEventTypes }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + + steps.forEach(async (step) => { + if (step.action !== WorkflowActions.SMS_ATTENDEE) { + //as we do not have attendees phone number (user is notified about that when setting this action) + bookingsForReminders.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + language: { locale: booking.user.locale || "" }, + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + if ( + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ATTENDEE /*|| + step.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (step.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + step.action, + { + time, + timeUnit, + }, + sendTo, + step.emailSubject || "", + step.reminderBody || "", + step.id, + step.template, + step.senderName || SENDER_NAME + ); + } else if (step.action === WorkflowActions.SMS_NUMBER) { + await scheduleSMSReminder( + bookingInfo, + step.sendTo || "", + trigger, + step.action, + { + time, + timeUnit, + }, + step.reminderBody || "", + step.id, + step.template, + step.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + }); + } + //create all workflow - eventtypes relationships + activeOn.forEach(async (eventTypeId) => { + await ctx.prisma.workflowsOnEventTypes.createMany({ + data: { + workflowId: id, + eventTypeId, + }, + }); + }); + } + + userWorkflow.steps.map(async (oldStep) => { + const newStep = steps.filter((s) => s.id === oldStep.id)[0]; + const remindersFromStep = await ctx.prisma.workflowReminder.findMany({ + where: { + workflowStepId: oldStep.id, + }, + include: { + booking: true, + }, + }); + + //step was deleted + if (!newStep) { + // cancel all workflow reminders from deleted steps + if (remindersFromStep.length > 0) { + remindersFromStep.forEach((reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + } + await ctx.prisma.workflowStep.delete({ + where: { + id: oldStep.id, + }, + }); + + //step was edited + } else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) { + if ( + !userWorkflow.teamId && + !userWorkflow.user?.teams.length && + !isSMSAction(oldStep.action) && + isSMSAction(newStep.action) + ) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + await ctx.prisma.workflowStep.update({ + where: { + id: oldStep.id, + }, + data: { + action: newStep.action, + sendTo: + newStep.action === WorkflowActions.SMS_NUMBER /*|| + newStep.action === WorkflowActions.EMAIL_ADDRESS*/ + ? newStep.sendTo + : null, + stepNumber: newStep.stepNumber, + workflowId: newStep.workflowId, + reminderBody: newStep.reminderBody, + emailSubject: newStep.emailSubject, + template: newStep.template, + numberRequired: newStep.numberRequired, + sender: getSender({ + action: newStep.action, + sender: newStep.sender || null, + senderName: newStep.senderName, + }), + numberVerificationPending: false, + }, + }); + //cancel all reminders of step and create new ones (not for newEventTypes) + const remindersToUpdate = remindersFromStep.filter((reminder) => { + if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) { + return reminder; + } + }); + + //cancel all workflow reminders from steps that were edited + remindersToUpdate.forEach(async (reminder) => { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.id, reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.id, reminder.referenceId); + } + }); + const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => { + if (!newEventTypes.includes(eventTypeId)) { + return eventTypeId; + } + }); + if ( + eventTypesToUpdateReminders && + (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) + ) { + const bookingsOfEventTypes = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { + in: eventTypesToUpdateReminders, + }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + bookingsOfEventTypes.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + language: { locale: booking.user.locale || "" }, + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + if ( + newStep.action === WorkflowActions.EMAIL_HOST || + newStep.action === WorkflowActions.EMAIL_ATTENDEE /*|| + newStep.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (newStep.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = newStep.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + newStep.action, + { + time, + timeUnit, + }, + sendTo, + newStep.emailSubject || "", + newStep.reminderBody || "", + newStep.id, + newStep.template, + newStep.senderName || SENDER_NAME + ); + } else if (newStep.action === WorkflowActions.SMS_NUMBER) { + await scheduleSMSReminder( + bookingInfo, + newStep.sendTo || "", + trigger, + newStep.action, + { + time, + timeUnit, + }, + newStep.reminderBody || "", + newStep.id || 0, + newStep.template, + newStep.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + } + }); + //added steps + const addedSteps = steps.map((s) => { + if (s.id <= 0) { + if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + const { id: _stepId, ...stepToAdd } = s; + return stepToAdd; + } + }); + + if (addedSteps) { + const eventTypesToCreateReminders = activeOn.map((activeEventType) => { + if (activeEventType && !newEventTypes.includes(activeEventType)) { + return activeEventType; + } + }); + addedSteps.forEach(async (step) => { + if (step) { + const { senderName, ...newStep } = step; + newStep.sender = getSender({ + action: newStep.action, + sender: newStep.sender || null, + senderName: senderName, + }); + const createdStep = await ctx.prisma.workflowStep.create({ + data: { ...newStep, numberVerificationPending: false }, + }); + if ( + (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) && + eventTypesToCreateReminders && + step.action !== WorkflowActions.SMS_ATTENDEE + ) { + const bookingsForReminders = await ctx.prisma.booking.findMany({ + where: { + eventTypeId: { in: eventTypesToCreateReminders as number[] }, + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + include: { + attendees: true, + eventType: true, + user: true, + }, + }); + bookingsForReminders.forEach(async (booking) => { + const bookingInfo = { + uid: booking.uid, + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || "" }, + }; + }), + organizer: booking.user + ? { + name: booking.user.name || "", + email: booking.user.email, + timeZone: booking.user.timeZone, + language: { locale: booking.user.locale || "" }, + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || "" }, + eventType: { + slug: booking.eventType?.slug, + }, + }; + + if ( + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_HOST /*|| + step.action === WorkflowActions.EMAIL_ADDRESS*/ + ) { + let sendTo = ""; + + switch (step.action) { + case WorkflowActions.EMAIL_HOST: + sendTo = bookingInfo.organizer?.email; + break; + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees[0].email; + break; + /*case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo || "";*/ + } + + await scheduleEmailReminder( + bookingInfo, + trigger, + step.action, + { + time, + timeUnit, + }, + sendTo, + step.emailSubject || "", + step.reminderBody || "", + createdStep.id, + step.template, + step.senderName || SENDER_NAME + ); + } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { + await scheduleSMSReminder( + bookingInfo, + step.sendTo, + trigger, + step.action, + { + time, + timeUnit, + }, + step.reminderBody || "", + createdStep.id, + step.template, + step.sender || SENDER_ID, + user.id, + userWorkflow.teamId + ); + } + }); + } + } + }); + } + + //update trigger, name, time, timeUnit + await ctx.prisma.workflow.update({ + where: { + id, + }, + data: { + name, + trigger, + time, + timeUnit, + }, + }); + + const workflow = await ctx.prisma.workflow.findFirst({ + where: { + id, + }, + include: { + activeOn: { + select: { + eventType: true, + }, + }, + team: { + select: { + id: true, + slug: true, + members: true, + }, + }, + steps: { + orderBy: { + stepNumber: "asc", + }, + }, + }, + }); + + // Remove or add booking field for sms reminder number + const smsReminderNumberNeeded = + activeOn.length && steps.some((step) => step.action === WorkflowActions.SMS_ATTENDEE); + + for (const removedEventType of removedEventTypes) { + await removeSmsReminderFieldForBooking({ + workflowId: id, + eventTypeId: removedEventType, + }); + } + + for (const eventTypeId of activeOn) { + if (smsReminderNumberNeeded) { + await upsertSmsReminderFieldForBooking({ + workflowId: id, + isSmsReminderNumberRequired: steps.some( + (s) => s.action === WorkflowActions.SMS_ATTENDEE && s.numberRequired + ), + eventTypeId, + }); + } else { + await removeSmsReminderFieldForBooking({ workflowId: id, eventTypeId }); + } + } + + return { + workflow, + }; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/update.schema.ts b/packages/trpc/server/routers/viewer/workflows/update.schema.ts new file mode 100644 index 0000000000..0234ffa7f0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/update.schema.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { + TIME_UNIT, + WORKFLOW_ACTIONS, + WORKFLOW_TEMPLATES, + WORKFLOW_TRIGGER_EVENTS, +} from "@calcom/features/ee/workflows/lib/constants"; + +export const ZUpdateInputSchema = z.object({ + id: z.number(), + name: z.string(), + activeOn: z.number().array(), + steps: z + .object({ + id: z.number(), + stepNumber: z.number(), + action: z.enum(WORKFLOW_ACTIONS), + workflowId: z.number(), + sendTo: z.string().optional().nullable(), + reminderBody: z.string().optional().nullable(), + emailSubject: z.string().optional().nullable(), + template: z.enum(WORKFLOW_TEMPLATES), + numberRequired: z.boolean().nullable(), + sender: z.string().optional().nullable(), + senderName: z.string().optional().nullable(), + }) + .array(), + trigger: z.enum(WORKFLOW_TRIGGER_EVENTS), + time: z.number().nullable(), + timeUnit: z.enum(TIME_UNIT).nullable(), +}); + +export type TUpdateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts new file mode 100644 index 0000000000..01ee25b7eb --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -0,0 +1,116 @@ +import type { Workflow } from "@prisma/client"; +import { MembershipRole } from "@prisma/client"; + +import { isSMSAction } from "@calcom/ee/workflows/lib/actionHelperFunctions"; +import { + getSmsReminderNumberField, + getSmsReminderNumberSource, + SMS_REMINDER_NUMBER_FIELD, +} from "@calcom/features/bookings/lib/getBookingFields"; +import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager"; +import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; +import type PrismaType from "@calcom/prisma"; +import type { WorkflowStep } from "@calcom/prisma/client"; + +export function getSender( + step: Pick & { senderName: string | null | undefined } +) { + return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; +} + +export async function isAuthorized( + workflow: Pick | null, + prisma: typeof PrismaType, + currentUserId: number, + readOnly?: boolean +) { + if (!workflow) { + return false; + } + + if (!readOnly) { + const userWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + }, + }, + }, + }, + ], + }, + }); + if (userWorkflow) return true; + } + + const userWorkflow = await prisma.workflow.findFirst({ + where: { + id: workflow.id, + OR: [ + { userId: currentUserId }, + { + team: { + members: { + some: { + userId: currentUserId, + accepted: true, + NOT: { + role: MembershipRole.MEMBER, + }, + }, + }, + }, + }, + ], + }, + }); + + if (userWorkflow) return true; + + return false; +} + +export async function upsertSmsReminderFieldForBooking({ + workflowId, + eventTypeId, + isSmsReminderNumberRequired, +}: { + workflowId: number; + isSmsReminderNumberRequired: boolean; + eventTypeId: number; +}) { + await upsertBookingField( + getSmsReminderNumberField(), + getSmsReminderNumberSource({ + workflowId, + isSmsReminderNumberRequired, + }), + eventTypeId + ); +} + +export async function removeSmsReminderFieldForBooking({ + workflowId, + eventTypeId, +}: { + workflowId: number; + eventTypeId: number; +}) { + await removeBookingField( + { + name: SMS_REMINDER_NUMBER_FIELD, + }, + { + id: "" + workflowId, + type: "workflow", + }, + eventTypeId + ); +} diff --git a/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts new file mode 100644 index 0000000000..708c2e3733 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.handler.ts @@ -0,0 +1,18 @@ +import { verifyPhoneNumber } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TVerifyPhoneNumberInputSchema } from "./verifyPhoneNumber.schema"; + +type VerifyPhoneNumberOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyPhoneNumberInputSchema; +}; + +export const verifyPhoneNumberHandler = async ({ ctx, input }: VerifyPhoneNumberOptions) => { + const { phoneNumber, code, teamId } = input; + const { user } = ctx; + const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId); + return verifyStatus; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts new file mode 100644 index 0000000000..fb3a02537b --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyPhoneNumber.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZVerifyPhoneNumberInputSchema = z.object({ + phoneNumber: z.string(), + code: z.string(), + teamId: z.number().optional(), +}); + +export type TVerifyPhoneNumberInputSchema = z.infer; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 27f51624cc..6d4d36b10d 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -92,15 +92,20 @@ async function getUserFromSession({ session }: { session: Maybe }) { }; } +export type TrpcSessionUser = Awaited>; + const t = initTRPC.context().create({ transformer: superjson, }); const perfMiddleware = t.middleware(async ({ path, type, next }) => { performance.mark("Start"); + const start = performance.now(); const result = await next(); + const end = performance.now(); performance.mark("End"); performance.measure(`[${result.ok ? "OK" : "ERROR"}][$1] ${type} '${path}'`, "Start", "End"); + console.log(`[${result.ok ? "OK" : "ERROR"}][${end - start}ms] ${type} '${path}'`); return result; }); From 4abf2d67d9768a7d888f4b04bf8c50cc1539dbb2 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 26 Apr 2023 10:29:54 +0200 Subject: [PATCH 04/12] removed devcontainer (#8521) --- .devcontainer/devcontainer.json | 42 --------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index e196dae09e..0000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,42 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker -{ - "name": "Docker outside of Docker", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/base:bullseye", - - "features": { - "ghcr.io/devcontainers/features/docker-from-docker:1": { - "version": "latest", - "enableNonRootDocker": "true", - "moby": "true" - }, - "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers-contrib/features/npm-package:1": {}, - "ghcr.io/devcontainers-contrib/features/jest:2": {}, - "ghcr.io/devcontainers-contrib/features/prisma:2": {}, - "ghcr.io/guiyomh/features/vim:0": {} - }, - - // Use this environment variable if you need to bind mount your local source code into a new container. - "remoteEnv": { - "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" - }, - - "hostRequirements": { - "cpus": 4, - "memory": "8gb" - }, - - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "./deploy/install.sh" - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} From 543466f1b9fd8f30d4a2d2ec0aa45c2fc06b75fd Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 26 Apr 2023 08:30:45 +0000 Subject: [PATCH 05/12] New Crowdin translations by Github Action --- apps/web/public/static/locales/fr/common.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 42426e4b96..fa4207268a 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -465,6 +465,7 @@ "friday": "Vendredi", "saturday": "Samedi", "sunday": "Dimanche", + "all_booked_today": "Tout est réservé.", "slots_load_fail": "Impossible de charger les créneaux disponibles.", "additional_guests": "Ajouter des invités", "your_name": "Votre nom", @@ -1792,6 +1793,7 @@ "seats_and_no_show_fee_error": "Il n'est pas possible d'activer les places et de facturer des frais d'absence pour le moment", "complete_your_booking": "Terminer votre réservation", "complete_your_booking_subject": "Terminer votre réservation : {{title}} le {{date}}", + "confirm_your_details": "Confirmez vos coordonnées", "currency_string": "{{amount, currency}}", "charge_card_dialog_body": "Vous êtes sur le point de facturer {{amount, currency}} au participant. Voulez-vous vraiment continuer ?", "charge_attendee": "Facturer {{amount, currency}} au participant", From 51695ee79d02ffbaf8a8c245d183b4f6be74baa2 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 26 Apr 2023 16:33:34 +0530 Subject: [PATCH 06/12] chore/AppStore: Remove deprecated imageSrc and make the paths relative in logo (#8479) * Remove depreated imageSrc and make the paths relative in logo * Do logo replacement at a single place * Make logo relative at other places * Fix campfire page not working in dev environment --- apps/web/pages/apps/[slug]/index.tsx | 9 ++++++--- packages/app-store/_appRegistry.ts | 4 ---- packages/app-store/amie/DESCRIPTION.md | 6 +++--- packages/app-store/amie/config.json | 3 +-- packages/app-store/appStoreMetaData.ts | 16 ++++++++++++---- packages/app-store/applecalendar/DESCRIPTION.md | 2 +- packages/app-store/applecalendar/_metadata.ts | 3 +-- packages/app-store/around/DESCRIPTION.md | 16 ++++++++-------- packages/app-store/around/config.json | 2 +- packages/app-store/caldavcalendar/DESCRIPTION.md | 2 +- packages/app-store/caldavcalendar/_metadata.ts | 4 ++-- packages/app-store/caldavcalendar/index.ts | 3 +-- packages/app-store/campfire/DESCRIPTION.md | 10 +++++----- packages/app-store/campfire/config.json | 3 +-- packages/app-store/closecom/DESCRIPTION.md | 10 +++++----- packages/app-store/closecom/config.json | 3 +-- packages/app-store/cron/config.json | 1 - packages/app-store/dailyvideo/DESCRIPTION.md | 6 +++--- packages/app-store/dailyvideo/_metadata.ts | 3 +-- packages/app-store/discord/config.json | 3 +-- .../app-store/exchange2013calendar/_metadata.ts | 3 +-- .../app-store/exchange2016calendar/_metadata.ts | 3 +-- packages/app-store/exchangecalendar/config.json | 4 ++-- packages/app-store/facetime/DESCRIPTION.md | 4 ++-- packages/app-store/facetime/config.json | 3 +-- packages/app-store/fathom/DESCRIPTION.md | 2 +- packages/app-store/fathom/config.json | 3 +-- packages/app-store/ga4/DESCRIPTION.md | 10 +++++----- packages/app-store/ga4/config.json | 3 +-- packages/app-store/getAppAssetFullPath.ts | 10 ++++++++++ packages/app-store/giphy/DESCRIPTION.md | 4 ++-- packages/app-store/giphy/_metadata.ts | 4 +--- packages/app-store/googlecalendar/DESCRIPTION.md | 4 ++-- packages/app-store/googlecalendar/_metadata.ts | 2 +- packages/app-store/googlevideo/DESCRIPTION.md | 4 ++-- packages/app-store/googlevideo/_metadata.ts | 3 +-- packages/app-store/gtm/config.json | 1 - packages/app-store/hubspot/DESCRIPTION.md | 2 +- packages/app-store/hubspot/_metadata.ts | 3 +-- packages/app-store/huddle01video/DESCRIPTION.md | 12 ++++++------ packages/app-store/huddle01video/_metadata.ts | 3 +-- packages/app-store/jitsivideo/DESCRIPTION.md | 2 +- packages/app-store/jitsivideo/_metadata.ts | 3 +-- packages/app-store/larkcalendar/DESCRIPTION.md | 8 ++++---- packages/app-store/larkcalendar/_metadata.ts | 3 +-- packages/app-store/n8n/DESCRIPTION.md | 6 +++--- packages/app-store/n8n/config.json | 3 +-- .../app-store/office365calendar/DESCRIPTION.md | 8 ++++---- .../app-store/office365calendar/_metadata.ts | 4 ++-- packages/app-store/office365video/DESCRIPTION.md | 10 +++++----- packages/app-store/office365video/config.json | 3 +-- packages/app-store/ping/DESCRIPTION.md | 6 +++--- packages/app-store/ping/config.json | 3 +-- packages/app-store/pipedream/DESCRIPTION.md | 10 +++++----- packages/app-store/pipedream/config.json | 3 +-- packages/app-store/plausible/DESCRIPTION.md | 2 +- packages/app-store/plausible/config.json | 3 +-- packages/app-store/qr_code/config.json | 3 +-- packages/app-store/rainbow/DESCRIPTION.md | 6 +++--- packages/app-store/rainbow/config.json | 3 +-- packages/app-store/raycast/DESCRIPTION.md | 8 ++++---- packages/app-store/raycast/config.json | 3 +-- packages/app-store/riverside/DESCRIPTION.md | 2 +- packages/app-store/riverside/config.json | 3 +-- packages/app-store/routing-forms/DESCRIPTION.md | 6 +++--- packages/app-store/routing-forms/config.json | 3 +-- packages/app-store/salesforce/DESCRIPTION.md | 2 +- packages/app-store/salesforce/config.json | 3 +-- packages/app-store/sendgrid/DESCRIPTION.md | 2 +- packages/app-store/sendgrid/config.json | 3 +-- packages/app-store/signal/DESCRIPTION.md | 4 ++-- packages/app-store/signal/config.json | 3 +-- packages/app-store/sirius_video/DESCRIPTION.md | 6 +++--- packages/app-store/sirius_video/config.json | 3 +-- packages/app-store/stripepayment/DESCRIPTION.md | 10 +++++----- packages/app-store/stripepayment/_metadata.ts | 3 +-- packages/app-store/sylapsvideo/config.json | 1 - packages/app-store/tandemvideo/DESCRIPTION.md | 12 ++++++------ packages/app-store/tandemvideo/_metadata.ts | 3 +-- packages/app-store/telegram/DESCRIPTION.md | 6 +++--- packages/app-store/telegram/config.json | 3 +-- packages/app-store/typeform/DESCRIPTION.md | 4 ++-- packages/app-store/typeform/config.json | 2 +- packages/app-store/utils.ts | 5 +---- packages/app-store/vimcal/DESCRIPTION.md | 8 ++++---- packages/app-store/vimcal/config.json | 3 +-- packages/app-store/vital/_metadata.ts | 4 +--- .../weather_in_your_calendar/DESCRIPTION.md | 4 ++-- .../weather_in_your_calendar/config.json | 3 +-- packages/app-store/whatsapp/DESCRIPTION.md | 6 +++--- packages/app-store/whatsapp/config.json | 3 +-- packages/app-store/whereby/DESCRIPTION.md | 4 ++-- packages/app-store/whereby/config.json | 3 +-- packages/app-store/wipemycalother/DESCRIPTION.md | 2 +- packages/app-store/wipemycalother/_metadata.ts | 3 +-- packages/app-store/wordpress/config.json | 3 +-- packages/app-store/zapier/DESCRIPTION.md | 4 ++-- packages/app-store/zapier/_metadata.ts | 3 +-- packages/app-store/zohocrm/config.json | 1 - packages/app-store/zoomvideo/DESCRIPTION.md | 14 +++++++------- packages/app-store/zoomvideo/_metadata.ts | 3 +-- packages/types/App.d.ts | 5 ----- packages/ui/components/apps/_storybookData.ts | 2 -- 103 files changed, 210 insertions(+), 253 deletions(-) create mode 100644 packages/app-store/getAppAssetFullPath.ts diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index adc35ee727..c961f547e2 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -6,6 +6,7 @@ import path from "path"; import { z } from "zod"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath"; import prisma from "@calcom/prisma"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -108,9 +109,11 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => { const { content, data } = sourceSchema.parse({ content: result.content, data: result.data }); if (data.items) { data.items = data.items.map((item) => { - if (typeof item === "string" && !item.includes("/api/app-store")) { - // Make relative paths absolute - return `/api/app-store/${appDirname}/${item}`; + if (typeof item === "string") { + return getAppAssetFullPath(item, { + dirName: singleApp.dirName, + isTemplate: singleApp.isTemplate, + }); } return item; }); diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 7b76150770..b8b979d3cf 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -11,10 +11,6 @@ export async function getAppWithMetadata(app: { dirName: string }) { // Let's not leak api keys to the front end // eslint-disable-next-line @typescript-eslint/no-unused-vars const { key, ...metadata } = appMetadata; - if (metadata.logo && !metadata.logo.includes("/api/app-store/")) { - const appDirName = `${metadata.isTemplate ? "templates" : ""}/${app.dirName}`; - metadata.logo = `/api/app-store/${appDirName}/${metadata.logo}`; - } return metadata; } diff --git a/packages/app-store/amie/DESCRIPTION.md b/packages/app-store/amie/DESCRIPTION.md index 011c2862b2..7ff45b2441 100644 --- a/packages/app-store/amie/DESCRIPTION.md +++ b/packages/app-store/amie/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/amie/1.jpg - - /api/app-store/amie/2.jpg - - /api/app-store/amie/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- diff --git a/packages/app-store/amie/config.json b/packages/app-store/amie/config.json index b83e145802..cd7c7d06b9 100644 --- a/packages/app-store/amie/config.json +++ b/packages/app-store/amie/config.json @@ -3,8 +3,7 @@ "name": "Amie", "slug": "amie", "type": "amie_other", - "imageSrc": "/api/app-store/amie/icon.svg", - "logo": "/api/app-store/amie/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/amie", "variant": "other", "categories": ["calendar"], diff --git a/packages/app-store/appStoreMetaData.ts b/packages/app-store/appStoreMetaData.ts index 2b7ba7f5be..9410aa54d2 100644 --- a/packages/app-store/appStoreMetaData.ts +++ b/packages/app-store/appStoreMetaData.ts @@ -1,6 +1,7 @@ import type { AppMeta } from "@calcom/types/App"; import { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated"; +import { getAppAssetFullPath } from "./getAppAssetFullPath"; type RawAppStoreMetaData = typeof rawAppStoreMetadata; type AppStoreMetaData = { @@ -8,12 +9,19 @@ type AppStoreMetaData = { }; export const appStoreMetadata = {} as AppStoreMetaData; - for (const [key, value] of Object.entries(rawAppStoreMetadata)) { - appStoreMetadata[key as keyof typeof appStoreMetadata] = { + const dirName = "dirName" in value ? value.dirName : value.slug; + if (!dirName) { + throw new Error(`Couldn't derive dirName for app ${key}`); + } + const metadata = (appStoreMetadata[key as keyof typeof appStoreMetadata] = { appData: null, - dirName: "dirName" in value ? value.dirName : value.slug, + dirName, __template: "", ...value, - } as AppStoreMetaData[keyof AppStoreMetaData]; + } as AppStoreMetaData[keyof AppStoreMetaData]); + metadata.logo = getAppAssetFullPath(metadata.logo, { + dirName, + isTemplate: metadata.isTemplate, + }); } diff --git a/packages/app-store/applecalendar/DESCRIPTION.md b/packages/app-store/applecalendar/DESCRIPTION.md index 1c099aa437..f6aeb217b3 100644 --- a/packages/app-store/applecalendar/DESCRIPTION.md +++ b/packages/app-store/applecalendar/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/applecalendar/1.jpg + - 1.jpg --- Apple calendar runs both the macOS and iOS mobile operating systems. Offering online cloud backup of calendars using Apple’s iCloud service, it can sync with Google Calendar and Microsoft Exchange Server. Users can schedule events in their day that include time, location, duration, and extra notes. diff --git a/packages/app-store/applecalendar/_metadata.ts b/packages/app-store/applecalendar/_metadata.ts index 040610b547..1b468204a3 100644 --- a/packages/app-store/applecalendar/_metadata.ts +++ b/packages/app-store/applecalendar/_metadata.ts @@ -8,11 +8,10 @@ export const metadata = { installed: true, type: "apple_calendar", title: "Apple Calendar", - imageSrc: "/api/app-store/applecalendar/icon.svg", variant: "calendar", categories: ["calendar"], category: "calendar", - logo: "/api/app-store/applecalendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "apple-calendar", url: "https://cal.com/", diff --git a/packages/app-store/around/DESCRIPTION.md b/packages/app-store/around/DESCRIPTION.md index c03182bf88..b3be801ef9 100644 --- a/packages/app-store/around/DESCRIPTION.md +++ b/packages/app-store/around/DESCRIPTION.md @@ -1,13 +1,13 @@ --- items: - - /api/app-store/around/1.jpg - - /api/app-store/around/2.jpg - - /api/app-store/around/3.jpg - - /api/app-store/around/4.jpg - - /api/app-store/around/5.jpg - - /api/app-store/around/6.jpg - - /api/app-store/around/7.jpg - - /api/app-store/around/8.jpg + - 1.jpg + - 2.jpg + - 3.jpg + - 4.jpg + - 5.jpg + - 6.jpg + - 7.jpg + - 8.jpg --- Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together. diff --git a/packages/app-store/around/config.json b/packages/app-store/around/config.json index e695ea9590..f678b8f317 100644 --- a/packages/app-store/around/config.json +++ b/packages/app-store/around/config.json @@ -4,7 +4,7 @@ "title": "Around", "slug": "around", "type": "around_video", - "logo": "/api/app-store/around/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/around", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/caldavcalendar/DESCRIPTION.md b/packages/app-store/caldavcalendar/DESCRIPTION.md index 39f9e1c4cd..eac279edb7 100644 --- a/packages/app-store/caldavcalendar/DESCRIPTION.md +++ b/packages/app-store/caldavcalendar/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/caldavcalendar/1.jpg + - 1.jpg --- Caldav is a protocol that allows different clients/servers to access scheduling information on remote servers as well as schedule meetings with other users on the same server or other servers. It extends WebDAV specification and uses iCalendar format for the data. diff --git a/packages/app-store/caldavcalendar/_metadata.ts b/packages/app-store/caldavcalendar/_metadata.ts index 0f922c88c8..e342f74fb2 100644 --- a/packages/app-store/caldavcalendar/_metadata.ts +++ b/packages/app-store/caldavcalendar/_metadata.ts @@ -8,15 +8,15 @@ export const metadata = { installed: true, type: "caldav_calendar", title: "CalDav (Beta)", - imageSrc: "/api/app-store/caldavcalendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], - logo: "/api/app-store/caldavcalendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "caldav-calendar", url: "https://cal.com/", email: "ali@cal.com", + dirName: "caldavcalendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/caldavcalendar/index.ts b/packages/app-store/caldavcalendar/index.ts index 3ddb10e2a6..6520415a87 100644 --- a/packages/app-store/caldavcalendar/index.ts +++ b/packages/app-store/caldavcalendar/index.ts @@ -8,11 +8,10 @@ export const metadata = { installed: true, type: "caldav_calendar", title: "CalDav (Beta)", - imageSrc: "/api/app-store/caldavcalendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], - logo: "/api/app-store/caldavcalendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "caldav-calendar", url: "https://cal.com/", diff --git a/packages/app-store/campfire/DESCRIPTION.md b/packages/app-store/campfire/DESCRIPTION.md index f69dd985ac..fd2d5ec7ea 100644 --- a/packages/app-store/campfire/DESCRIPTION.md +++ b/packages/app-store/campfire/DESCRIPTION.md @@ -1,12 +1,12 @@ --- items: - - /api/app-store/campfire/1.jpg - - /api/app-store/campfire/2.jpg - - /api/app-store/campfire/3.jpg - - /api/app-store/campfire/4.jpg + - 1.jpg + - 2.jpg + - 3.jpg + - 4.jpg --- - ## Feel connected with your remote team diff --git a/packages/app-store/campfire/config.json b/packages/app-store/campfire/config.json index 74e3ef32ce..54b32fa9a8 100644 --- a/packages/app-store/campfire/config.json +++ b/packages/app-store/campfire/config.json @@ -3,8 +3,7 @@ "name": "Campfire", "slug": "campfire", "type": "campfire_video", - "imageSrc": "/api/app-store/campfire/icon.svg", - "logo": "/api/app-store/campfire/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/campfire", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/closecom/DESCRIPTION.md b/packages/app-store/closecom/DESCRIPTION.md index c1839449e1..c43c69d6bb 100644 --- a/packages/app-store/closecom/DESCRIPTION.md +++ b/packages/app-store/closecom/DESCRIPTION.md @@ -1,10 +1,10 @@ --- items: - - /api/app-store/closecom/1.jpg - - /api/app-store/closecom/2.jpg - - /api/app-store/closecom/3.jpg - - /api/app-store/closecom/4.jpg - - /api/app-store/closecom/5.jpg + - 1.jpg + - 2.jpg + - 3.jpg + - 4.jpg + - 5.jpg --- - Close is a modern CRM with build-in sales communication tools for email, phone, SMS, and meetings. diff --git a/packages/app-store/closecom/config.json b/packages/app-store/closecom/config.json index 169a365efb..dba31259e2 100644 --- a/packages/app-store/closecom/config.json +++ b/packages/app-store/closecom/config.json @@ -4,8 +4,7 @@ "title": "Close.com", "slug": "closecom", "type": "closecom_other_calendar", - "imageSrc": "/api/app-store/closecom/icon.svg", - "logo": "/api/app-store/closecom/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/closecom", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/cron/config.json b/packages/app-store/cron/config.json index 433dc1b734..955e73e41b 100644 --- a/packages/app-store/cron/config.json +++ b/packages/app-store/cron/config.json @@ -3,7 +3,6 @@ "name": "Cron", "slug": "cron", "type": "cron_other", - "imageSrc": "logo.png", "logo": "logo.png", "url": "https://cal.com/apps/cron", "variant": "other", diff --git a/packages/app-store/dailyvideo/DESCRIPTION.md b/packages/app-store/dailyvideo/DESCRIPTION.md index 05a0ee7968..60770a48d3 100644 --- a/packages/app-store/dailyvideo/DESCRIPTION.md +++ b/packages/app-store/dailyvideo/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/dailyvideo/1.jpg - - /api/app-store/dailyvideo/2.jpg - - /api/app-store/dailyvideo/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- - **Recordings require a team plan** diff --git a/packages/app-store/dailyvideo/_metadata.ts b/packages/app-store/dailyvideo/_metadata.ts index 19a90acf29..7af67924a3 100644 --- a/packages/app-store/dailyvideo/_metadata.ts +++ b/packages/app-store/dailyvideo/_metadata.ts @@ -7,11 +7,10 @@ export const metadata = { description: _package.description, installed: !!process.env.DAILY_API_KEY, type: "daily_video", - imageSrc: "/api/app-store/dailyvideo/icon.svg", variant: "conferencing", url: "https://daily.co", categories: ["video"], - logo: "/api/app-store/dailyvideo/icon.svg", + logo: "icon.svg", publisher: "Cal.com", category: "video", slug: "daily-video", diff --git a/packages/app-store/discord/config.json b/packages/app-store/discord/config.json index 0e3931a684..682593c46b 100644 --- a/packages/app-store/discord/config.json +++ b/packages/app-store/discord/config.json @@ -3,8 +3,7 @@ "name": "Discord", "slug": "discord", "type": "discord_video", - "imageSrc": "/api/app-store/discord/icon.svg", - "logo": "/api/app-store/discord/icon.svg", + "logo": "icon.svg", "url": "https://discord.com/", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/exchange2013calendar/_metadata.ts b/packages/app-store/exchange2013calendar/_metadata.ts index 9ed4a3e108..3d7c0734af 100644 --- a/packages/app-store/exchange2013calendar/_metadata.ts +++ b/packages/app-store/exchange2013calendar/_metadata.ts @@ -8,12 +8,11 @@ export const metadata = { installed: true, type: "exchange2013_calendar", title: "Microsoft Exchange 2013 Calendar", - imageSrc: "/api/app-store/exchange2013calendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], label: "Exchange Calendar", - logo: "/api/app-store/exchange2013calendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "exchange2013-calendar", url: "https://cal.com/", diff --git a/packages/app-store/exchange2016calendar/_metadata.ts b/packages/app-store/exchange2016calendar/_metadata.ts index a0e50eeba5..138b5e5442 100644 --- a/packages/app-store/exchange2016calendar/_metadata.ts +++ b/packages/app-store/exchange2016calendar/_metadata.ts @@ -8,12 +8,11 @@ export const metadata = { installed: true, type: "exchange2016_calendar", title: "Microsoft Exchange 2016 Calendar", - imageSrc: "/api/app-store/exchange2016calendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], label: "Exchange Calendar", - logo: "/api/app-store/exchange2016calendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "exchange2016-calendar", url: "https://cal.com/", diff --git a/packages/app-store/exchangecalendar/config.json b/packages/app-store/exchangecalendar/config.json index 934899c856..d49c3e4de5 100644 --- a/packages/app-store/exchangecalendar/config.json +++ b/packages/app-store/exchangecalendar/config.json @@ -3,9 +3,9 @@ "title": "Microsoft Exchange", "name": "Microsoft Exchange", "slug": "exchange", + "dirName": "exchangecalendar", "type": "exchange_calendar", - "imageSrc": "/api/app-store/exchangecalendar/icon.svg", - "logo": "/api/app-store/exchangecalendar/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/exchange", "variant": "calendar", "categories": ["calendar"], diff --git a/packages/app-store/facetime/DESCRIPTION.md b/packages/app-store/facetime/DESCRIPTION.md index 6dbf65a2d2..ab2e873c43 100644 --- a/packages/app-store/facetime/DESCRIPTION.md +++ b/packages/app-store/facetime/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/facetime/facetime1.png - - /api/app-store/facetime/facetime2.png + - facetime1.png + - facetime2.png --- With FaceTime, it’s easy to stay in touch. You can make audio and video calls with up to 32 people, share your screen, enjoy films and music together, and more. diff --git a/packages/app-store/facetime/config.json b/packages/app-store/facetime/config.json index d652a24519..671999675c 100644 --- a/packages/app-store/facetime/config.json +++ b/packages/app-store/facetime/config.json @@ -4,8 +4,7 @@ "title": "Facetime", "slug": "facetime", "type": "facetime_video", - "imageSrc": "/api/app-store/facetime/icon.svg", - "logo": "/api/app-store/facetime/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/facetime", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/fathom/DESCRIPTION.md b/packages/app-store/fathom/DESCRIPTION.md index 3e16d4a3db..3c9100d054 100644 --- a/packages/app-store/fathom/DESCRIPTION.md +++ b/packages/app-store/fathom/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/fathom/1.jpg + - 1.jpg --- Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative. diff --git a/packages/app-store/fathom/config.json b/packages/app-store/fathom/config.json index 435053f0f9..cf60fdfea1 100644 --- a/packages/app-store/fathom/config.json +++ b/packages/app-store/fathom/config.json @@ -3,8 +3,7 @@ "name": "Fathom", "slug": "fathom", "type": "fathom_analytics", - "imageSrc": "/api/app-store/fathom/icon.svg", - "logo": "/api/app-store/fathom/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/fathom", "variant": "analytics", "categories": ["analytics"], diff --git a/packages/app-store/ga4/DESCRIPTION.md b/packages/app-store/ga4/DESCRIPTION.md index a2d034ac15..ece4528ab3 100644 --- a/packages/app-store/ga4/DESCRIPTION.md +++ b/packages/app-store/ga4/DESCRIPTION.md @@ -1,11 +1,11 @@ --- description: Google Analytics is a web analytics service offered by Google that tracks and reports website traffic, currently as a platform inside the Google Marketing Platform brand. items: - - /api/app-store/ga4/1.jpeg - - /api/app-store/ga4/2.jpeg - - /api/app-store/ga4/3.jpeg - - /api/app-store/ga4/4.jpeg - - /api/app-store/ga4/5.jpeg + - 1.jpeg + - 2.jpeg + - 3.jpeg + - 4.jpeg + - 5.jpeg --- Google Analytics is a web analytics service offered by Google that tracks and reports website traffic, currently as a platform inside the Google Marketing Platform brand. diff --git a/packages/app-store/ga4/config.json b/packages/app-store/ga4/config.json index 1e8d2bc78b..b0bdb496d5 100644 --- a/packages/app-store/ga4/config.json +++ b/packages/app-store/ga4/config.json @@ -3,8 +3,7 @@ "name": "Google Analytics", "slug": "ga4", "type": "ga4_analytics", - "imageSrc": "/api/app-store/ga4/icon.svg", - "logo": "/api/app-store/ga4/icon.svg", + "logo": "icon.svg", "url": "https://marketingplatform.google.com", "variant": "analytics", "categories": ["analytics"], diff --git a/packages/app-store/getAppAssetFullPath.ts b/packages/app-store/getAppAssetFullPath.ts new file mode 100644 index 0000000000..5f1a98afec --- /dev/null +++ b/packages/app-store/getAppAssetFullPath.ts @@ -0,0 +1,10 @@ +import type { App } from "@calcom/types/App"; + +export function getAppAssetFullPath(assetPath: string, metadata: Pick) { + const appDirName = `${metadata.isTemplate ? "templates/" : ""}${metadata.dirName}`; + let assetFullPath = assetPath; + if (!assetPath.startsWith("/app-store/") && !/^https?/.test(assetPath)) { + assetFullPath = `/app-store/${appDirName}/${assetPath}`; + } + return assetFullPath; +} diff --git a/packages/app-store/giphy/DESCRIPTION.md b/packages/app-store/giphy/DESCRIPTION.md index d06290ee24..8a652c93bd 100644 --- a/packages/app-store/giphy/DESCRIPTION.md +++ b/packages/app-store/giphy/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/giphy/GIPHY1.png - - /api/app-store/giphy/GIPHY2.png + - GIPHY1.png + - GIPHY2.png --- An online database and search engine that allows users to search for and share short looping videos with no sound that resemble animated GIF files. GIPHY is your top source for the best & newest GIFs & Animated Stickers online. Find everything from funny GIFs, reaction GIFs, unique GIFs and more to add to your custom booking page. Located under advanced settings in each event type. diff --git a/packages/app-store/giphy/_metadata.ts b/packages/app-store/giphy/_metadata.ts index ff99e7e1ad..050f4ab234 100644 --- a/packages/app-store/giphy/_metadata.ts +++ b/packages/app-store/giphy/_metadata.ts @@ -7,9 +7,7 @@ export const metadata = { description: _package.description, installed: true, categories: ["other"], - // If using static next public folder, can then be referenced from the base URL (/). - imageSrc: "/api/app-store/giphy/icon.svg", - logo: "/api/app-store/giphy/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "giphy", title: "Giphy", diff --git a/packages/app-store/googlecalendar/DESCRIPTION.md b/packages/app-store/googlecalendar/DESCRIPTION.md index 4723093520..d7e903b593 100644 --- a/packages/app-store/googlecalendar/DESCRIPTION.md +++ b/packages/app-store/googlecalendar/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/googlecalendar/GCal1.png - - /api/app-store/googlecalendar/GCal2.png + - GCal1.png + - GCal2.png --- Google Calendar is a time management and scheduling service developed by Google. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Gmail account on both mobile and web versions. diff --git a/packages/app-store/googlecalendar/_metadata.ts b/packages/app-store/googlecalendar/_metadata.ts index d17a4fc05f..dbdb831035 100644 --- a/packages/app-store/googlecalendar/_metadata.ts +++ b/packages/app-store/googlecalendar/_metadata.ts @@ -12,7 +12,7 @@ export const metadata = { variant: "calendar", category: "calendar", categories: ["calendar"], - logo: "/api/app-store/googlecalendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "google-calendar", url: "https://cal.com/", diff --git a/packages/app-store/googlevideo/DESCRIPTION.md b/packages/app-store/googlevideo/DESCRIPTION.md index b0abb0e118..bec0b02fa5 100644 --- a/packages/app-store/googlevideo/DESCRIPTION.md +++ b/packages/app-store/googlevideo/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/googlevideo/gmeet1.png - - /api/app-store/googlevideo/gmeet2.png + - gmeet1.png + - gmeet2.png --- Google Meet is Google's web-based video conferencing platform, designed to compete with major conferencing platforms. diff --git a/packages/app-store/googlevideo/_metadata.ts b/packages/app-store/googlevideo/_metadata.ts index 4e5d68232c..69f296fd49 100644 --- a/packages/app-store/googlevideo/_metadata.ts +++ b/packages/app-store/googlevideo/_metadata.ts @@ -12,9 +12,8 @@ export const metadata = { categories: ["video"], type: "google_video", title: "Google Meet", - imageSrc: "/api/app-store/googlevideo/logo.webp", variant: "conferencing", - logo: "/api/app-store/googlevideo/logo.webp", + logo: "logo.webp", publisher: "Cal.com", url: "https://cal.com/", isGlobal: false, diff --git a/packages/app-store/gtm/config.json b/packages/app-store/gtm/config.json index 8560bf05fc..c691e6f7ef 100644 --- a/packages/app-store/gtm/config.json +++ b/packages/app-store/gtm/config.json @@ -2,7 +2,6 @@ "name": "Google Tag Manager", "slug": "gtm", "type": "gtm_analytics", - "imageSrc": "icon.svg", "logo": "icon.svg", "url": "https://tagmanager.google.com", "variant": "analytics", diff --git a/packages/app-store/hubspot/DESCRIPTION.md b/packages/app-store/hubspot/DESCRIPTION.md index a1e911fac1..12c3d18c1e 100644 --- a/packages/app-store/hubspot/DESCRIPTION.md +++ b/packages/app-store/hubspot/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/hubspot/hubspot01.webp + - hubspot01.webp --- HubSpot is a cloud-based CRM designed to help align sales and marketing teams, foster sales enablement, boost ROI and optimize your inbound marketing strategy to generate more, qualified leads. diff --git a/packages/app-store/hubspot/_metadata.ts b/packages/app-store/hubspot/_metadata.ts index 5989134667..2f27976d4a 100644 --- a/packages/app-store/hubspot/_metadata.ts +++ b/packages/app-store/hubspot/_metadata.ts @@ -7,9 +7,8 @@ export const metadata = { installed: !!process.env.HUBSPOT_CLIENT_ID, description: _package.description, type: "hubspot_other_calendar", - imageSrc: "/api/app-store/hubspot/icon.svg", variant: "other_calendar", - logo: "/api/app-store/hubspot/icon.svg", + logo: "icon.svg", publisher: "Cal.com", url: "https://hubspot.com/", categories: ["other"], diff --git a/packages/app-store/huddle01video/DESCRIPTION.md b/packages/app-store/huddle01video/DESCRIPTION.md index ea51ab64a8..bc4d1aa798 100644 --- a/packages/app-store/huddle01video/DESCRIPTION.md +++ b/packages/app-store/huddle01video/DESCRIPTION.md @@ -1,11 +1,11 @@ --- items: - - /api/app-store/huddle01video/1.png - - /api/app-store/huddle01video/2.png - - /api/app-store/huddle01video/3.png - - /api/app-store/huddle01video/4.png - - /api/app-store/huddle01video/5.png - - /api/app-store/huddle01video/6.png + - 1.png + - 2.png + - 3.png + - 4.png + - 5.png + - 6.png --- Huddle01 is a new video conferencing software native to Web3 and is comparable to a decentralized version of Zoom. It supports conversations for NFT communities, DAOs, Builders and also has features such as token gating, NFTs as avatars, Web3 Login + ENS and recording over IPFS. diff --git a/packages/app-store/huddle01video/_metadata.ts b/packages/app-store/huddle01video/_metadata.ts index 59a1530fc5..1b8399b4bc 100644 --- a/packages/app-store/huddle01video/_metadata.ts +++ b/packages/app-store/huddle01video/_metadata.ts @@ -8,10 +8,9 @@ export const metadata = { description: _package.description, installed: true, type: "huddle01_video", - imageSrc: "/api/app-store/huddle01video/icon.svg", variant: "conferencing", categories: ["video", "web3"], - logo: "/api/app-store/huddle01video/icon.svg", + logo: "icon.svg", publisher: "huddle01.com", url: "https://huddle01.com", category: "web3", diff --git a/packages/app-store/jitsivideo/DESCRIPTION.md b/packages/app-store/jitsivideo/DESCRIPTION.md index 3e3869302b..ee6231b3bb 100644 --- a/packages/app-store/jitsivideo/DESCRIPTION.md +++ b/packages/app-store/jitsivideo/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/jitsivideo/jitsi1.jpg + - jitsi1.jpg --- Jitsi is a free open-source video conferencing software for web and mobile. Make a call, launch on your own servers, integrate into your app, and more. diff --git a/packages/app-store/jitsivideo/_metadata.ts b/packages/app-store/jitsivideo/_metadata.ts index a47c0add14..1e08501c04 100644 --- a/packages/app-store/jitsivideo/_metadata.ts +++ b/packages/app-store/jitsivideo/_metadata.ts @@ -7,10 +7,9 @@ export const metadata = { description: _package.description, installed: true, type: "jitsi_video", - imageSrc: "/api/app-store/jitsivideo/icon.svg", variant: "conferencing", categories: ["video"], - logo: "/api/app-store/jitsivideo/icon.svg", + logo: "icon.svg", publisher: "Cal.com", url: "https://jitsi.org/", slug: "jitsi", diff --git a/packages/app-store/larkcalendar/DESCRIPTION.md b/packages/app-store/larkcalendar/DESCRIPTION.md index 46c310275d..d0a7a8785d 100644 --- a/packages/app-store/larkcalendar/DESCRIPTION.md +++ b/packages/app-store/larkcalendar/DESCRIPTION.md @@ -1,9 +1,9 @@ --- items: - - /api/app-store/larkcalendar/1.png - - /api/app-store/larkcalendar/2.png - - /api/app-store/larkcalendar/3.png - - /api/app-store/larkcalendar/4.png + - 1.png + - 2.png + - 3.png + - 4.png --- diff --git a/packages/app-store/larkcalendar/_metadata.ts b/packages/app-store/larkcalendar/_metadata.ts index 3f4ee077ed..050e2ed7d8 100644 --- a/packages/app-store/larkcalendar/_metadata.ts +++ b/packages/app-store/larkcalendar/_metadata.ts @@ -8,10 +8,9 @@ export const metadata = { installed: true, type: "lark_calendar", title: "Lark Calendar", - imageSrc: "/api/app-store/larkcalendar/icon.svg", variant: "calendar", categories: ["calendar"], - logo: "/api/app-store/larkcalendar/icon.svg", + logo: "icon.svg", publisher: "Lark", slug: "lark-calendar", url: "https://larksuite.com/", diff --git a/packages/app-store/n8n/DESCRIPTION.md b/packages/app-store/n8n/DESCRIPTION.md index a10f1ba537..fe467ed651 100644 --- a/packages/app-store/n8n/DESCRIPTION.md +++ b/packages/app-store/n8n/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/n8n/1.png - - /api/app-store/n8n/2.png - - /api/app-store/n8n/3.png + - 1.png + - 2.png + - 3.png - https://docs.n8n.io/_images/integrations/builtin/credentials/cal/getting-api-key.gif --- diff --git a/packages/app-store/n8n/config.json b/packages/app-store/n8n/config.json index 5f929129d7..6fbee1ccfd 100644 --- a/packages/app-store/n8n/config.json +++ b/packages/app-store/n8n/config.json @@ -3,8 +3,7 @@ "name": "n8n", "slug": "n8n", "type": "n8n_automation", - "imageSrc": "/api/app-store/n8n/icon.svg", - "logo": "/api/app-store/n8n/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/n8n", "variant": "automation", "categories": ["automation"], diff --git a/packages/app-store/office365calendar/DESCRIPTION.md b/packages/app-store/office365calendar/DESCRIPTION.md index 3cb2959012..08425303af 100644 --- a/packages/app-store/office365calendar/DESCRIPTION.md +++ b/packages/app-store/office365calendar/DESCRIPTION.md @@ -1,9 +1,9 @@ --- items: - - /api/app-store/office365calendar/1.jpg - - /api/app-store/office365calendar/2.jpg - - /api/app-store/office365calendar/3.jpg - - /api/app-store/office365calendar/4.jpg + - 1.jpg + - 2.jpg + - 3.jpg + - 4.jpg --- Microsoft Office 365 is a suite of apps that helps you stay connected with others and get things done. It includes but is not limited to Microsoft Word, PowerPoint, Excel, Teams, OneNote and OneDrive. Office 365 allows you to work remotely with others on a team and collaborate in an online environment. Both web versions and desktop/mobile applications are available. diff --git a/packages/app-store/office365calendar/_metadata.ts b/packages/app-store/office365calendar/_metadata.ts index 9ef8774e3d..6389322c54 100644 --- a/packages/app-store/office365calendar/_metadata.ts +++ b/packages/app-store/office365calendar/_metadata.ts @@ -7,13 +7,13 @@ export const metadata = { description: _package.description, type: "office365_calendar", title: "Outlook Calendar", - imageSrc: "/api/app-store/office365calendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], - logo: "/api/app-store/office365calendar/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "office365-calendar", + dirName: "office365calendar", url: "https://cal.com/", email: "help@cal.com", } as AppMeta; diff --git a/packages/app-store/office365video/DESCRIPTION.md b/packages/app-store/office365video/DESCRIPTION.md index cb880bdf98..1c06d3ab8e 100644 --- a/packages/app-store/office365video/DESCRIPTION.md +++ b/packages/app-store/office365video/DESCRIPTION.md @@ -1,10 +1,10 @@ --- items: - - /api/app-store/office365video/teams1.png - - /api/app-store/office365video/teams2.png - - /api/app-store/office365video/teams3.jpeg - - /api/app-store/office365video/teams4.png - - /api/app-store/office365video/teams5.png + - teams1.png + - teams2.png + - teams3.jpeg + - teams4.png + - teams5.png --- Microsoft Teams is a business communication platform and collaborative workspace included in Microsoft 365. It offers workspace chat and video conferencing, file storage, and application integration. Both web versions and desktop/mobile applications are available. NOTE: MUST HAVE A WORK / SCHOOL ACCOUNT diff --git a/packages/app-store/office365video/config.json b/packages/app-store/office365video/config.json index 6f0ba838e3..7e7c1d39ca 100644 --- a/packages/app-store/office365video/config.json +++ b/packages/app-store/office365video/config.json @@ -2,9 +2,8 @@ "name": "Microsoft 365/Teams (Requires work/school account)", "description": "Microsoft Teams is a business communication platform and collaborative workspace included in Microsoft 365. It offers workspace chat and video conferencing, file storage, and application integration. Both web versions and desktop/mobile applications are available. NOTE: MUST HAVE A WORK / SCHOOL ACCOUNT", "type": "office365_video", - "imageSrc": "/api/app-store/office365video/icon.svg", "variant": "conferencing", - "logo": "/api/app-store/office365video/icon.svg", + "logo": "icon.svg", "publisher": "Cal.com", "url": "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software", "verified": true, diff --git a/packages/app-store/ping/DESCRIPTION.md b/packages/app-store/ping/DESCRIPTION.md index 5048413745..a35949a7ab 100644 --- a/packages/app-store/ping/DESCRIPTION.md +++ b/packages/app-store/ping/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/ping/1.png - - /api/app-store/ping/2.png - - /api/app-store/ping/3.png + - 1.png + - 2.png + - 3.png --- Ping.gg makes high quality video collaborations easier than ever. Think "Zoom for streamers and creators". Join a call in 3 clicks, manage audio and video like a pro, and copy-paste your guests straight into OBS diff --git a/packages/app-store/ping/config.json b/packages/app-store/ping/config.json index a199ec7896..65902d169f 100644 --- a/packages/app-store/ping/config.json +++ b/packages/app-store/ping/config.json @@ -4,8 +4,7 @@ "title": "Ping.gg", "slug": "ping", "type": "ping_video", - "imageSrc": "/api/app-store/ping/icon.svg", - "logo": "/api/app-store/ping/icon.svg", + "logo": "icon.svg", "url": "https://ping.gg", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/pipedream/DESCRIPTION.md b/packages/app-store/pipedream/DESCRIPTION.md index f67ef0996b..6ceb82886d 100644 --- a/packages/app-store/pipedream/DESCRIPTION.md +++ b/packages/app-store/pipedream/DESCRIPTION.md @@ -1,11 +1,11 @@ --- description: Connect APIs, remarkably fast. Stop writing boilerplate code, struggling with authentication and managing infrastructure. Start connecting APIs with code-level control when you need it — and no code when you don't items: - - /api/app-store/pipedream/1.png - - /api/app-store/pipedream/2.png - - /api/app-store/pipedream/3.png - - /api/app-store/pipedream/4.png - - /api/app-store/pipedream/5.png + - 1.png + - 2.png + - 3.png + - 4.png + - 5.png --- Connect APIs, remarkably fast. Stop writing boilerplate code, struggling with authentication and managing infrastructure. Start connecting APIs with code-level control when you need it — and no code when you don't diff --git a/packages/app-store/pipedream/config.json b/packages/app-store/pipedream/config.json index a696fa49d1..37fa14a103 100644 --- a/packages/app-store/pipedream/config.json +++ b/packages/app-store/pipedream/config.json @@ -3,8 +3,7 @@ "name": "Pipedream", "slug": "pipedream", "type": "pipedream_automation", - "imageSrc": "/api/app-store/pipedream/icon.svg", - "logo": "/api/app-store/pipedream/icon.svg", + "logo": "icon.svg", "url": "https://pipedream.com/apps/cal-com", "variant": "automation", "categories": ["automation"], diff --git a/packages/app-store/plausible/DESCRIPTION.md b/packages/app-store/plausible/DESCRIPTION.md index b69e4ce790..969505ea11 100644 --- a/packages/app-store/plausible/DESCRIPTION.md +++ b/packages/app-store/plausible/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/plausible/1.jpg + - 1.jpg --- Simple, privacy-friendly Google Analytics alternative. diff --git a/packages/app-store/plausible/config.json b/packages/app-store/plausible/config.json index e46024e0f1..5e41e7eb08 100644 --- a/packages/app-store/plausible/config.json +++ b/packages/app-store/plausible/config.json @@ -3,8 +3,7 @@ "name": "Plausible", "slug": "plausible", "type": "plausible_analytics", - "imageSrc": "/api/app-store/plausible/icon.svg", - "logo": "/api/app-store/plausible/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/plausible", "variant": "analytics", "categories": ["analytics"], diff --git a/packages/app-store/qr_code/config.json b/packages/app-store/qr_code/config.json index 1237f6e180..27d32efdba 100644 --- a/packages/app-store/qr_code/config.json +++ b/packages/app-store/qr_code/config.json @@ -3,8 +3,7 @@ "name": "QR Code", "slug": "qr_code", "type": "qr_code_other", - "imageSrc": "/api/app-store/qr_code/icon.svg", - "logo": "/api/app-store/qr_code/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/qr_code", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/rainbow/DESCRIPTION.md b/packages/app-store/rainbow/DESCRIPTION.md index cde42e0c27..280c348bed 100644 --- a/packages/app-store/rainbow/DESCRIPTION.md +++ b/packages/app-store/rainbow/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/rainbow/1.jpg - - /api/app-store/rainbow/2.jpg - - /api/app-store/rainbow/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- Token gate bookings based on NFTs, DAO tokens, and ERC-20 tokens. Rainbow supports dozens of trusted Ethereum wallet apps to verify token ownership. Available blockchains are Ethereum mainnet, Arbitrum, Optimism, and Polygon mainnet. diff --git a/packages/app-store/rainbow/config.json b/packages/app-store/rainbow/config.json index e103185e3b..c02784d3d2 100644 --- a/packages/app-store/rainbow/config.json +++ b/packages/app-store/rainbow/config.json @@ -3,8 +3,7 @@ "name": "Rainbow", "slug": "rainbow", "type": "rainbow_web3", - "imageSrc": "/api/app-store/rainbow/icon.svg", - "logo": "/api/app-store/rainbow/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/rainbow", "variant": "web3", "categories": ["web3"], diff --git a/packages/app-store/raycast/DESCRIPTION.md b/packages/app-store/raycast/DESCRIPTION.md index a53617800c..db35b56d2b 100644 --- a/packages/app-store/raycast/DESCRIPTION.md +++ b/packages/app-store/raycast/DESCRIPTION.md @@ -1,9 +1,9 @@ --- items: - - /api/app-store/raycast/1.png - - /api/app-store/raycast/2.png - - /api/app-store/raycast/3.png - - /api/app-store/raycast/4.png + - 1.png + - 2.png + - 3.png + - 4.png --- Quickly share your Cal.com meeting links with Raycast. Requires Raycast.com to be installed. You can create an API token in your Developer Cal.com Settings. diff --git a/packages/app-store/raycast/config.json b/packages/app-store/raycast/config.json index 2a4205df77..7cf2906ee3 100644 --- a/packages/app-store/raycast/config.json +++ b/packages/app-store/raycast/config.json @@ -3,8 +3,7 @@ "name": "Raycast", "slug": "raycast", "type": "raycast_other", - "imageSrc": "/api/app-store/raycast/icon.svg", - "logo": "/api/app-store/raycast/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/raycast", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/riverside/DESCRIPTION.md b/packages/app-store/riverside/DESCRIPTION.md index aaa4e29f03..09f1d23a9a 100644 --- a/packages/app-store/riverside/DESCRIPTION.md +++ b/packages/app-store/riverside/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/riverside/riverside1.png + - riverside1.png --- Your online recording studio. The easiest way to record podcasts and videos in studio quality from anywhere. All from the browser. diff --git a/packages/app-store/riverside/config.json b/packages/app-store/riverside/config.json index 957242e2cf..f32a84d95b 100644 --- a/packages/app-store/riverside/config.json +++ b/packages/app-store/riverside/config.json @@ -3,8 +3,7 @@ "name": "Riverside", "slug": "riverside", "type": "riverside_video", - "imageSrc": "/api/app-store/riverside/icon-dark.svg", - "logo": "/api/app-store/riverside/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://cal.com/apps/riverside", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/routing-forms/DESCRIPTION.md b/packages/app-store/routing-forms/DESCRIPTION.md index 4970fa3f01..6084b58176 100644 --- a/packages/app-store/routing-forms/DESCRIPTION.md +++ b/packages/app-store/routing-forms/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/routing-forms/1.jpg - - /api/app-store/routing-forms/2.jpg - - /api/app-store/routing-forms/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user diff --git a/packages/app-store/routing-forms/config.json b/packages/app-store/routing-forms/config.json index 01335feb13..a5dbf1fb39 100644 --- a/packages/app-store/routing-forms/config.json +++ b/packages/app-store/routing-forms/config.json @@ -4,8 +4,7 @@ "title": "Routing Forms", "slug": "routing-forms", "type": "routing-forms_other", - "imageSrc": "/api/app-store/routing-forms/icon-dark.svg", - "logo": "/api/app-store/routing-forms/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://cal.com/apps/routing-forms", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/salesforce/DESCRIPTION.md b/packages/app-store/salesforce/DESCRIPTION.md index 661e418310..618966cb39 100644 --- a/packages/app-store/salesforce/DESCRIPTION.md +++ b/packages/app-store/salesforce/DESCRIPTION.md @@ -1,7 +1,7 @@ --- description: Salesforce (Sales Cloud) is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day. items: - - /api/app-store/salesforce/1.png + - 1.png --- Salesforce (Sales Cloud) is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day. diff --git a/packages/app-store/salesforce/config.json b/packages/app-store/salesforce/config.json index 855f1166df..31a93fc0f5 100644 --- a/packages/app-store/salesforce/config.json +++ b/packages/app-store/salesforce/config.json @@ -3,8 +3,7 @@ "name": "Salesforce", "slug": "salesforce", "type": "salesforce_other_calendar", - "imageSrc": "/api/app-store/salesforce/icon.png", - "logo": "/api/app-store/salesforce/icon.png", + "logo": "icon.png", "url": "https://cal.com/apps/salesforce", "variant": "other_calendar", "categories": ["other"], diff --git a/packages/app-store/sendgrid/DESCRIPTION.md b/packages/app-store/sendgrid/DESCRIPTION.md index c434583c32..6358c2dfb6 100644 --- a/packages/app-store/sendgrid/DESCRIPTION.md +++ b/packages/app-store/sendgrid/DESCRIPTION.md @@ -1,7 +1,7 @@ --- description: SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform. items: - - /api/app-store/sendgrid/1.png + - 1.png --- SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform. diff --git a/packages/app-store/sendgrid/config.json b/packages/app-store/sendgrid/config.json index 3dc3936858..a224a85480 100644 --- a/packages/app-store/sendgrid/config.json +++ b/packages/app-store/sendgrid/config.json @@ -3,8 +3,7 @@ "name": "Sendgrid", "slug": "sendgrid", "type": "sendgrid_other_calendar", - "imageSrc": "/api/app-store/sendgrid/logo.png", - "logo": "/api/app-store/sendgrid/logo.png", + "logo": "logo.png", "url": "https://cal.com/apps/sendgrid", "variant": "other_calendar", "categories": ["other"], diff --git a/packages/app-store/signal/DESCRIPTION.md b/packages/app-store/signal/DESCRIPTION.md index 4a9026d2ab..c76f5b09b7 100644 --- a/packages/app-store/signal/DESCRIPTION.md +++ b/packages/app-store/signal/DESCRIPTION.md @@ -1,8 +1,8 @@ --- description: Schedule a chat with your guests or have a Signal Video call. items: - - /api/app-store/signal/1.jpg - - /api/app-store/signal/2.jpg + - 1.jpg + - 2.jpg --- Schedule a chat with your guests or have a Signal Video call. diff --git a/packages/app-store/signal/config.json b/packages/app-store/signal/config.json index 4b1cdd2621..087dab979e 100644 --- a/packages/app-store/signal/config.json +++ b/packages/app-store/signal/config.json @@ -3,8 +3,7 @@ "name": "Signal", "slug": "signal", "type": "signal_video", - "imageSrc": "/api/app-store/signal/icon.svg", - "logo": "/api/app-store/signal/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/signal", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/sirius_video/DESCRIPTION.md b/packages/app-store/sirius_video/DESCRIPTION.md index e03b1ffad3..36399b2e97 100644 --- a/packages/app-store/sirius_video/DESCRIPTION.md +++ b/packages/app-store/sirius_video/DESCRIPTION.md @@ -1,9 +1,9 @@ --- description: Video meetings made for music. Create your own virtual music classroom, easily. items: - - /api/app-store/sirius_video/1.jpg - - /api/app-store/sirius_video/2.jpg - - /api/app-store/sirius_video/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- Video meetings made for music. Create your own virtual music classroom, easily. diff --git a/packages/app-store/sirius_video/config.json b/packages/app-store/sirius_video/config.json index 120add1178..4a4ef0c734 100644 --- a/packages/app-store/sirius_video/config.json +++ b/packages/app-store/sirius_video/config.json @@ -3,8 +3,7 @@ "name": "Sirius Video", "slug": "sirius_video", "type": "sirius_video_video", - "imageSrc": "/api/app-store/sirius_video/icon-dark.svg", - "logo": "/api/app-store/sirius_video/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://cal.com/apps/sirius_video", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/stripepayment/DESCRIPTION.md b/packages/app-store/stripepayment/DESCRIPTION.md index 8a30ef11a3..90744b9ab7 100644 --- a/packages/app-store/stripepayment/DESCRIPTION.md +++ b/packages/app-store/stripepayment/DESCRIPTION.md @@ -1,10 +1,10 @@ --- items: - - /api/app-store/stripepayment/stripe1.jpg - - /api/app-store/stripepayment/stripe2.jpg - - /api/app-store/stripepayment/stripe3.jpg - - /api/app-store/stripepayment/stripe4.jpg - - /api/app-store/stripepayment/stripe5.jpg + - stripe1.jpg + - stripe2.jpg + - stripe3.jpg + - stripe4.jpg + - stripe5.jpg --- Stripe provides payment infrastructure for everyone from startups to Fortune 500 companies. They provide payment processing software as well as application programming interfaces (APIs) for mobile applications as well as e-commerce websites processing payments from (but not limited to) credit cards, debit cards, digital wallets, Google Pay, Apple Pay, Bank Transfers, Alipay and WeChat. diff --git a/packages/app-store/stripepayment/_metadata.ts b/packages/app-store/stripepayment/_metadata.ts index 56b63a5ecf..26bd34023d 100644 --- a/packages/app-store/stripepayment/_metadata.ts +++ b/packages/app-store/stripepayment/_metadata.ts @@ -13,8 +13,7 @@ export const metadata = { slug: "stripe", category: "payment", categories: ["payment"], - logo: "/api/app-store/stripepayment/icon.svg", - imageSrc: "/api/app-store/stripepayment/icon.svg", + logo: "icon.svg", publisher: "Cal.com", title: "Stripe", type: "stripe_payment", diff --git a/packages/app-store/sylapsvideo/config.json b/packages/app-store/sylapsvideo/config.json index 4e0f836aed..e225795664 100644 --- a/packages/app-store/sylapsvideo/config.json +++ b/packages/app-store/sylapsvideo/config.json @@ -4,7 +4,6 @@ "title": "Sylaps", "slug": "sylapsvideo", "type": "sylaps_video", - "imageSrc": "icon.svg", "logo": "icon.svg", "url": "https://cal.com/apps/sylaps", "variant": "conferencing", diff --git a/packages/app-store/tandemvideo/DESCRIPTION.md b/packages/app-store/tandemvideo/DESCRIPTION.md index 57a2496663..02e76e2e49 100644 --- a/packages/app-store/tandemvideo/DESCRIPTION.md +++ b/packages/app-store/tandemvideo/DESCRIPTION.md @@ -1,11 +1,11 @@ --- items: - - /api/app-store/tandemvideo/tandem1.jpg - - /api/app-store/tandemvideo/tandem2.jpg - - /api/app-store/tandemvideo/tandem3.jpg - - /api/app-store/tandemvideo/tandem4.jpg - - /api/app-store/tandemvideo/tandem5.jpg - - /api/app-store/tandemvideo/tandem6.jpg + - tandem1.jpg + - tandem2.jpg + - tandem3.jpg + - tandem4.jpg + - tandem5.jpg + - tandem6.jpg --- Tandem is a new virtual office space that allows teams to effortlessly connect as though they are in a physical office, online. Through co-working rooms, available statuses, live real-time video call, and chat options, you can see who’s around, talk and collaborate in one click. It works cross-platform with both desktop and mobile versions. diff --git a/packages/app-store/tandemvideo/_metadata.ts b/packages/app-store/tandemvideo/_metadata.ts index 42d484b3a9..592a96c832 100644 --- a/packages/app-store/tandemvideo/_metadata.ts +++ b/packages/app-store/tandemvideo/_metadata.ts @@ -7,12 +7,11 @@ export const metadata = { description: _package.description, type: "tandem_video", title: "Tandem Video", - imageSrc: "/api/app-store/tandemvideo/icon.svg", variant: "conferencing", categories: ["video"], slug: "tandem", category: "video", - logo: "/api/app-store/tandemvideo/icon.svg", + logo: "icon.svg", publisher: "", url: "", isGlobal: false, diff --git a/packages/app-store/telegram/DESCRIPTION.md b/packages/app-store/telegram/DESCRIPTION.md index 3b5bed8492..89ec750b62 100644 --- a/packages/app-store/telegram/DESCRIPTION.md +++ b/packages/app-store/telegram/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/telegram/1.jpg - - /api/app-store/telegram/2.jpg - - /api/app-store/telegram/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- Schedule a chat with your guests or have a Telegram Video call. diff --git a/packages/app-store/telegram/config.json b/packages/app-store/telegram/config.json index e36861cf10..f30ce1e7a7 100644 --- a/packages/app-store/telegram/config.json +++ b/packages/app-store/telegram/config.json @@ -3,8 +3,7 @@ "name": "Telegram", "slug": "telegram", "type": "telegram_video", - "imageSrc": "/api/app-store/telegram/icon.svg", - "logo": "/api/app-store/telegram/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/telegram", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/typeform/DESCRIPTION.md b/packages/app-store/typeform/DESCRIPTION.md index 611805bf88..c86c37777f 100644 --- a/packages/app-store/typeform/DESCRIPTION.md +++ b/packages/app-store/typeform/DESCRIPTION.md @@ -2,8 +2,8 @@ description: Adds a link to copy Typeform Redirect URL to integrate with Routing Forms. items: - iframe: { src: https://www.youtube.com/embed/vcV-N2gLPZc } - - /api/app-store/typeform/copy-typeform-redirect-url.png - - /api/app-store/typeform/how-it-looks-in-typeform.png + - copy-typeform-redirect-url.png + - how-it-looks-in-typeform.png --- diff --git a/packages/app-store/typeform/config.json b/packages/app-store/typeform/config.json index 878d9c1dff..4dd2467ad4 100644 --- a/packages/app-store/typeform/config.json +++ b/packages/app-store/typeform/config.json @@ -3,7 +3,7 @@ "name": "Typeform", "slug": "typeform", "type": "typeform_other", - "logo": "/api/app-store/typeform/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://cal.com/apps/typeform", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 9b488d77b4..13fc9aa106 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -19,10 +19,7 @@ type LocationOption = { const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => { const metadata = appStoreMetadata[key as keyof typeof appStoreMetadata] as AppMeta; - if (metadata.logo && !metadata.logo.includes("/")) { - const appDirName = `${metadata.isTemplate ? "templates" : ""}/${metadata.slug}`; - metadata.logo = `/api/app-store/${appDirName}/${metadata.logo}`; - } + store[key] = metadata; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/app-store/vimcal/DESCRIPTION.md b/packages/app-store/vimcal/DESCRIPTION.md index a030749cee..904334627b 100644 --- a/packages/app-store/vimcal/DESCRIPTION.md +++ b/packages/app-store/vimcal/DESCRIPTION.md @@ -1,10 +1,10 @@ --- description: The world's fastest calendar, beautifully designed for a remote world items: - - /api/app-store/vimcal/1.gif - - /api/app-store/vimcal/2.gif - - /api/app-store/vimcal/3.gif - - /api/app-store/vimcal/4.gif + - 1.gif + - 2.gif + - 3.gif + - 4.gif --- The world's fastest calendar, beautifully designed for a remote world diff --git a/packages/app-store/vimcal/config.json b/packages/app-store/vimcal/config.json index be6ae60f88..96c9d9ac7b 100644 --- a/packages/app-store/vimcal/config.json +++ b/packages/app-store/vimcal/config.json @@ -3,8 +3,7 @@ "name": "Vimcal", "slug": "vimcal", "type": "vimcal_other", - "imageSrc": "/api/app-store/vimcal/icon.svg", - "logo": "/api/app-store/vimcal/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/vimcal", "variant": "other", "categories": ["calendar"], diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts index dbbec335d3..b1b48d2323 100644 --- a/packages/app-store/vital/_metadata.ts +++ b/packages/app-store/vital/_metadata.ts @@ -8,9 +8,7 @@ export const metadata = { installed: true, category: "other", categories: ["other"], - // If using static next public folder, can then be referenced from the base URL (/). - imageSrc: "/api/app-store/vital/icon.svg", - logo: "/api/app-store/vital/icon.svg", + logo: "icon.svg", label: "Vital", publisher: "Vital", slug: "vital-automation", diff --git a/packages/app-store/weather_in_your_calendar/DESCRIPTION.md b/packages/app-store/weather_in_your_calendar/DESCRIPTION.md index 369ce84541..103c4af8bd 100644 --- a/packages/app-store/weather_in_your_calendar/DESCRIPTION.md +++ b/packages/app-store/weather_in_your_calendar/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/weather_in_your_calendar/1.jpeg - - /api/app-store/weather_in_your_calendar/2.jpeg + - 1.jpeg + - 2.jpeg --- You can now get the weather forecast directly into your calendar. This local weather calendar uses emojis ⛅️ 🌧️ ☀️ 🌨️ to display a 16 days forecast from OpenWeatherMap. Enter your city, adjust according to your preferences and subscribe to your calendar. diff --git a/packages/app-store/weather_in_your_calendar/config.json b/packages/app-store/weather_in_your_calendar/config.json index 4dbbc81f48..d5c58c7a1e 100644 --- a/packages/app-store/weather_in_your_calendar/config.json +++ b/packages/app-store/weather_in_your_calendar/config.json @@ -3,8 +3,7 @@ "name": "Weather in your Calendar", "slug": "weather_in_your_calendar", "type": "weather_in_your_calendar_other", - "imageSrc": "/api/app-store/weather_in_your_calendar/icon.gif", - "logo": "/api/app-store/weather_in_your_calendar/icon.gif", + "logo": "icon.gif", "url": "https://weather-in-calendar.com", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/whatsapp/DESCRIPTION.md b/packages/app-store/whatsapp/DESCRIPTION.md index 88ee147287..2be3a36d86 100644 --- a/packages/app-store/whatsapp/DESCRIPTION.md +++ b/packages/app-store/whatsapp/DESCRIPTION.md @@ -1,8 +1,8 @@ --- items: - - /api/app-store/whatsapp/1.jpg - - /api/app-store/whatsapp/2.jpg - - /api/app-store/whatsapp/3.jpg + - 1.jpg + - 2.jpg + - 3.jpg --- Schedule a chat with your guests or have a WhatsApp Video call. diff --git a/packages/app-store/whatsapp/config.json b/packages/app-store/whatsapp/config.json index 6ce34f58e5..7ac388cfd7 100644 --- a/packages/app-store/whatsapp/config.json +++ b/packages/app-store/whatsapp/config.json @@ -3,8 +3,7 @@ "name": "WhatsApp", "slug": "whatsapp", "type": "whatsapp_video", - "imageSrc": "/api/app-store/whatsapp/icon.svg", - "logo": "/api/app-store/whatsapp/icon.svg", + "logo": "icon.svg", "url": "https://cal.com/apps/whatsapp", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/whereby/DESCRIPTION.md b/packages/app-store/whereby/DESCRIPTION.md index 5477309ffc..83e5a0f673 100644 --- a/packages/app-store/whereby/DESCRIPTION.md +++ b/packages/app-store/whereby/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/whereby/whereby1.webp - - /api/app-store/whereby/whereby2.webp + - whereby1.webp + - whereby2.webp --- Whereby's the easiest way to connect over video – with no app or software download required. Connect with anyone, anywhere with zero hassle. diff --git a/packages/app-store/whereby/config.json b/packages/app-store/whereby/config.json index 959ae5ae8a..6607fada11 100644 --- a/packages/app-store/whereby/config.json +++ b/packages/app-store/whereby/config.json @@ -4,8 +4,7 @@ "title": "Whereby", "slug": "whereby", "type": "whereby_video", - "imageSrc": "/api/app-store/whereby/icon-dark.svg", - "logo": "/api/app-store/whereby/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://cal.com/apps/whereby", "variant": "conferencing", "categories": ["video"], diff --git a/packages/app-store/wipemycalother/DESCRIPTION.md b/packages/app-store/wipemycalother/DESCRIPTION.md index c3ca9dd669..0fda815978 100644 --- a/packages/app-store/wipemycalother/DESCRIPTION.md +++ b/packages/app-store/wipemycalother/DESCRIPTION.md @@ -1,6 +1,6 @@ --- items: - - /api/app-store/wipemycalother/1.jpg + - 1.jpg --- diff --git a/packages/app-store/wipemycalother/_metadata.ts b/packages/app-store/wipemycalother/_metadata.ts index 7b90892398..0e06bb5623 100644 --- a/packages/app-store/wipemycalother/_metadata.ts +++ b/packages/app-store/wipemycalother/_metadata.ts @@ -9,8 +9,7 @@ export const metadata = { category: "other", categories: ["other"], // If using static next public folder, can then be referenced from the base URL (/). - imageSrc: "/api/app-store/wipemycalother/icon-dark.svg", - logo: "/api/app-store/wipemycalother/icon-dark.svg", + logo: "icon-dark.svg", publisher: "Cal.com", slug: "wipe-my-cal", title: "Wipe my cal", diff --git a/packages/app-store/wordpress/config.json b/packages/app-store/wordpress/config.json index fd93647e00..260e7411a0 100644 --- a/packages/app-store/wordpress/config.json +++ b/packages/app-store/wordpress/config.json @@ -3,8 +3,7 @@ "name": "Wordpress", "slug": "wordpress", "type": "wordpress_other", - "imageSrc": "/api/app-store/wordpress/icon-dark.svg", - "logo": "/api/app-store/wordpress/icon-dark.svg", + "logo": "icon-dark.svg", "url": "https://github.com/calcom/wp-plugin", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/zapier/DESCRIPTION.md b/packages/app-store/zapier/DESCRIPTION.md index 785dc7359c..3b72a1c123 100644 --- a/packages/app-store/zapier/DESCRIPTION.md +++ b/packages/app-store/zapier/DESCRIPTION.md @@ -1,7 +1,7 @@ --- items: - - /api/app-store/zapier/1.jpg - - /api/app-store/zapier/2.jpg + - 1.jpg + - 2.jpg --- Workflow automation for everyone. Use the Cal.com Zapier app to automate your workflows when a booking is created, rescheduled, cancelled or when a meeting ended.

**After Installation:** Have you lost your API key? You can always generate a new key on the **Zapier Setup Page** diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts index df2d6685ce..26b50fb9d2 100644 --- a/packages/app-store/zapier/_metadata.ts +++ b/packages/app-store/zapier/_metadata.ts @@ -8,8 +8,7 @@ export const metadata = { installed: true, category: "automation", categories: ["automation"], - imageSrc: "/api/app-store/zapier/icon.svg", - logo: "/api/app-store/zapier/icon.svg", + logo: "icon.svg", publisher: "Cal.com", slug: "zapier", title: "Zapier", diff --git a/packages/app-store/zohocrm/config.json b/packages/app-store/zohocrm/config.json index 47b58439af..d369cbf03e 100644 --- a/packages/app-store/zohocrm/config.json +++ b/packages/app-store/zohocrm/config.json @@ -3,7 +3,6 @@ "name": "ZohoCRM", "slug": "zohocrm", "type": "zohocrm_other_calendar", - "imageSrc": "icon.png", "logo": "icon.png", "url": "https://cal.com/apps/zohocrm", "variant": "other", diff --git a/packages/app-store/zoomvideo/DESCRIPTION.md b/packages/app-store/zoomvideo/DESCRIPTION.md index 9d5bb6e301..9e27b4c7e3 100644 --- a/packages/app-store/zoomvideo/DESCRIPTION.md +++ b/packages/app-store/zoomvideo/DESCRIPTION.md @@ -1,12 +1,12 @@ --- items: - - /api/app-store/zoomvideo/zoom1.jpg - - /api/app-store/zoomvideo/zoom2.png - - /api/app-store/zoomvideo/zoom3.png - - /api/app-store/zoomvideo/zoom4.png - - /api/app-store/zoomvideo/zoom5.jpg - - /api/app-store/zoomvideo/zoom6.png - - /api/app-store/zoomvideo/zoom7.png + - zoom1.jpg + - zoom2.png + - zoom3.png + - zoom4.png + - zoom5.jpg + - zoom6.png + - zoom7.png --- Zoom is a secure and reliable video platform that supports all of your online communication needs. It can provide everything from one on one meetings, chat, phone, webinars, and large-scale online events. Available with both desktop, web, and mobile versions. diff --git a/packages/app-store/zoomvideo/_metadata.ts b/packages/app-store/zoomvideo/_metadata.ts index 8819c88e67..15956ccb6c 100644 --- a/packages/app-store/zoomvideo/_metadata.ts +++ b/packages/app-store/zoomvideo/_metadata.ts @@ -8,9 +8,8 @@ export const metadata = { description: _package.description, type: "zoom_video", categories: ["video"], - imageSrc: "/api/app-store/zoomvideo/icon.svg", variant: "conferencing", - logo: "/api/app-store/zoomvideo/icon.svg", + logo: "icon.svg", publisher: "Cal.com", url: "https://zoom.us/", category: "video", diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts index c5f36726e4..6697345596 100644 --- a/packages/types/App.d.ts +++ b/packages/types/App.d.ts @@ -68,11 +68,6 @@ export interface App { name: string; /** A brief description, usually found in the app's package.json */ description: string; - /** - * @deprecated logo is used instead - * The icon to display in /apps/installed - */ - imageSrc?: string; /** TODO determine if we should use this instead of category */ variant: | "calendar" diff --git a/packages/ui/components/apps/_storybookData.ts b/packages/ui/components/apps/_storybookData.ts index d0af1640db..e0e2b5e301 100644 --- a/packages/ui/components/apps/_storybookData.ts +++ b/packages/ui/components/apps/_storybookData.ts @@ -7,7 +7,6 @@ export const _SBApps: App[] = [ installed: true, type: "google_calendar", title: "Google Calendar", - imageSrc: "/api/app-store/googlecalendar/icon.svg", variant: "calendar", category: "calendar", categories: ["calendar"], @@ -23,7 +22,6 @@ export const _SBApps: App[] = [ description: "Zoom Video", type: "zoom_video", categories: ["video"], - imageSrc: "/api/app-store/zoomvideo/icon.svg", variant: "conferencing", logo: "/api/app-store/zoomvideo/icon.svg", publisher: "Cal.com", From 55570ed4bfbc65d5b838211fc9bb487e791cef09 Mon Sep 17 00:00:00 2001 From: Kirat Date: Wed, 26 Apr 2023 18:50:18 +0530 Subject: [PATCH 07/12] Fixed discord validation (#8536) --- packages/app-store/discord/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/discord/config.json b/packages/app-store/discord/config.json index 682593c46b..ee93e56aba 100644 --- a/packages/app-store/discord/config.json +++ b/packages/app-store/discord/config.json @@ -15,7 +15,7 @@ "label": "{TITLE}", "linkType": "static", "organizerInputPlaceholder": "https://discord.gg/420gg69", - "urlRegExp": "^http(s)?:\\/\\/(www\\.)?discord.gg\\/[a-zA-Z0-9]*" + "urlRegExp": "^http(s)?:\\/\\/(www\\.)?(discord.gg|discord.com)\\/[a-zA-Z0-9]*" } }, "description": "Copy your server invite link and start scheduling calls in Discord! Discord is a VoIP and instant messaging social platform. Users have the ability to communicate with voice calls, video calls, text messaging, media and files in private chats or as part of communities.", From 7ff5b66f46dc743caa3dc2357e8e8d0770393819 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 26 Apr 2023 10:12:14 -0400 Subject: [PATCH 08/12] Bookings with no-show fee to set proper location & prevent broken payments from blocking payment page (#8508) * Better error logging on Stripe payment service * Update booking * If missing payment, load rest of bookings * Address feedback * Move requires confirmation threshold to webhook * Use requiresConfirmation * Revert unnecessary prettier changes --------- Co-authored-by: Hariom Balhara --- .../components/booking/BookingListItem.tsx | 16 +- apps/web/public/static/locales/en/common.json | 1 + .../stripepayment/lib/PaymentService.ts | 145 +++++++++--------- packages/features/ee/payments/api/webhook.ts | 33 +++- 4 files changed, 113 insertions(+), 82 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 0644aa5e0f..63a8a9fe2d 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -280,13 +280,13 @@ function BookingListItem(booking: BookingItemProps) { isOpenDialog={isOpenSetLocationDialog} setShowLocationModal={setIsOpenLocationDialog} /> - {booking.paid && ( + {booking.paid && booking.payment[0] && ( )} {showRecordingsButtons && ( @@ -354,11 +354,15 @@ function BookingListItem(booking: BookingItemProps) { {booking.eventType.team.name} )} - {booking.paid && ( + {booking.paid && !booking.payment[0] ? ( + + {t("error_collecting_card")} + + ) : booking.paid ? ( {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} - )} + ) : null} {recurringDates !== undefined && (
@@ -458,7 +462,7 @@ function BookingListItem(booking: BookingItemProps) {
)} - {booking.status === "ACCEPTED" && booking.paid && booking?.payment[0]?.paymentOption === "HOLD" && ( + {booking.status === "ACCEPTED" && booking.paid && booking.payment[0]?.paymentOption === "HOLD" && (
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d9b9200a9a..c001b9ddde 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1799,5 +1799,6 @@ "charge_attendee": "Charge attendee {{amount, currency}}", "payment_app_commission": "Require payment ({{paymentFeePercentage}}% + {{fee, currency}} commission per transaction)", "email_invite_team": "{{email}} has been invited", + "error_collecting_card": "Error collecting card", "image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit" } diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index ee18a9a8e7..e7155b1a79 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -113,7 +113,7 @@ export class PaymentService implements IAbstractPaymentService { } return paymentData; } catch (error) { - console.error(error); + console.error(`Payment could not be created for bookingId ${bookingId}`, error); throw new Error("Payment could not be created"); } } @@ -124,75 +124,80 @@ export class PaymentService implements IAbstractPaymentService { bookerEmail: string, paymentOption: PaymentOption ): Promise { - // Ensure that the payment service can support the passed payment option - if (paymentOptionEnum.parse(paymentOption) !== "HOLD") { - throw new Error("Payment option is not compatible with create method"); + try { + // Ensure that the payment service can support the passed payment option + if (paymentOptionEnum.parse(paymentOption) !== "HOLD") { + throw new Error("Payment option is not compatible with create method"); + } + + // Load stripe keys + const stripeAppKeys = await prisma?.app.findFirst({ + select: { + keys: true, + }, + where: { + slug: "stripe", + }, + }); + + // Parse keys with zod + const { payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(stripeAppKeys?.keys); + const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed); + + const customer = await retrieveOrCreateStripeCustomerByEmail( + bookerEmail, + this.credentials.stripe_user_id + ); + + const params = { + customer: customer.id, + payment_method_types: ["card"], + metadata: { + bookingId, + }, + }; + + const setupIntent = await this.stripe.setupIntents.create(params, { + stripeAccount: this.credentials.stripe_user_id, + }); + + const paymentData = await prisma?.payment.create({ + data: { + uid: uuidv4(), + app: { + connect: { + slug: "stripe", + }, + }, + booking: { + connect: { + id: bookingId, + }, + }, + amount: payment.amount, + currency: payment.currency, + externalId: setupIntent.id, + + data: Object.assign( + {}, + { + setupIntent, + stripe_publishable_key: this.credentials.stripe_publishable_key, + stripeAccount: this.credentials.stripe_user_id, + } + ) as unknown as Prisma.InputJsonValue, + fee: paymentFee, + refunded: false, + success: false, + paymentOption: paymentOption || "ON_BOOKING", + }, + }); + + return paymentData; + } catch (error) { + console.error(`Payment method could not be collected for bookingId ${bookingId}`, error); + throw new Error("Payment could not be created"); } - - // Load stripe keys - const stripeAppKeys = await prisma?.app.findFirst({ - select: { - keys: true, - }, - where: { - slug: "stripe", - }, - }); - - // Parse keys with zod - const { payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(stripeAppKeys?.keys); - const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed); - - const customer = await retrieveOrCreateStripeCustomerByEmail( - bookerEmail, - this.credentials.stripe_user_id - ); - - const params = { - customer: customer.id, - payment_method_types: ["card"], - metadata: { - bookingId, - }, - }; - - const setupIntent = await this.stripe.setupIntents.create(params, { - stripeAccount: this.credentials.stripe_user_id, - }); - - const paymentData = await prisma?.payment.create({ - data: { - uid: uuidv4(), - app: { - connect: { - slug: "stripe", - }, - }, - booking: { - connect: { - id: bookingId, - }, - }, - amount: payment.amount, - currency: payment.currency, - externalId: setupIntent.id, - - data: Object.assign( - {}, - { - setupIntent, - stripe_publishable_key: this.credentials.stripe_publishable_key, - stripeAccount: this.credentials.stripe_user_id, - } - ) as unknown as Prisma.InputJsonValue, - fee: paymentFee, - refunded: false, - success: false, - paymentOption: paymentOption || "ON_BOOKING", - }, - }); - - return paymentData; } async chargeCard(payment: Payment): Promise { @@ -264,7 +269,7 @@ export class PaymentService implements IAbstractPaymentService { return paymentData; } catch (error) { - console.error(error); + console.error(`Could not charge card for payment ${payment.id}`, error); throw new Error("Payment could not be created"); } } diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 9bae3030e9..458b7a6cd1 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -6,6 +6,7 @@ import type Stripe from "stripe"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; import { sendScheduledEmails, sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; @@ -15,6 +16,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma, bookingMinimalSelect } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; export const config = { @@ -31,6 +33,7 @@ async function getEventType(id: number) { select: { recurringEvent: true, requiresConfirmation: true, + metadata: true, }, }); } @@ -73,6 +76,8 @@ async function getBooking(bookingId: number) { eventTypeRaw = await getEventType(booking.eventTypeId); } + const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) }; + const { user } = booking; if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" }); @@ -109,14 +114,14 @@ async function getBooking(bookingId: number) { attendees: attendeesList, uid: booking.uid, destinationCalendar: booking.destinationCalendar || user.destinationCalendar, - recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent), + recurringEvent: parseRecurringEvent(eventType?.recurringEvent), }; return { booking, user, evt, - eventTypeRaw, + eventType, }; } @@ -214,6 +219,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { language: { translate: t, locale: user.locale ?? "en" }, }, attendees: attendeesList, + location: booking.location, uid: booking.uid, destinationCalendar: booking.destinationCalendar || user.destinationCalendar, recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent), @@ -284,7 +290,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => { if (!payment?.data || !payment?.id) throw new HttpCode({ statusCode: 204, message: "Payment not found" }); - const { user, evt, eventTypeRaw } = await getBooking(payment.bookingId); + const { booking, user, evt, eventType } = await getBooking(payment.bookingId); const bookingData: Prisma.BookingUpdateInput = { paid: true, @@ -308,7 +314,15 @@ const handleSetupSuccess = async (event: Stripe.Event) => { if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" }); - if (!eventTypeRaw?.requiresConfirmation) { + let requiresConfirmation = eventType?.requiresConfirmation; + const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold; + if (rcThreshold) { + if (dayjs(dayjs(booking.startTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) { + requiresConfirmation = false; + } + } + + if (!requiresConfirmation) { const eventManager = new EventManager(userWithCredentials); const scheduleResult = await eventManager.create(evt); bookingData.references = { create: scheduleResult.referencesToCreate }; @@ -334,8 +348,15 @@ const handleSetupSuccess = async (event: Stripe.Event) => { // If the card information was already captured in the same customer. Delete the previous payment method - if (!eventTypeRaw?.requiresConfirmation) { - await sendScheduledEmails({ ...evt }); + if (!requiresConfirmation) { + await handleConfirmation({ + user: userWithCredentials, + evt, + prisma, + bookingId: booking.id, + booking, + paid: true, + }); } else { await sendOrganizerRequestEmail({ ...evt }); await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); From 82978240512981872fd3b6917b08462dd5ca52b9 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 26 Apr 2023 22:06:04 +0530 Subject: [PATCH 09/12] fix: edit mode set to true by default (#8468) --- packages/ui/components/editor/plugins/ToolbarPlugin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/components/editor/plugins/ToolbarPlugin.tsx b/packages/ui/components/editor/plugins/ToolbarPlugin.tsx index 9c6acdb905..adb30ae029 100644 --- a/packages/ui/components/editor/plugins/ToolbarPlugin.tsx +++ b/packages/ui/components/editor/plugins/ToolbarPlugin.tsx @@ -64,7 +64,7 @@ function FloatingLinkEditor({ editor }: { editor: LexicalEditor }) { const mouseDownRef = useRef(false); const inputRef = useRef(null); const [linkUrl, setLinkUrl] = useState(""); - const [isEditMode, setEditMode] = useState(false); + const [isEditMode, setEditMode] = useState(true); const [lastSelection, setLastSelection] = useState( null ); From 9c0f7bbfc765476d9dd2ffeb1323297f24466130 Mon Sep 17 00:00:00 2001 From: Abdurrahman Rajab Date: Thu, 27 Apr 2023 11:07:32 +0300 Subject: [PATCH 10/12] Chore: fix RTL style in Color picker, Return arrow, Two factor page (#8317) * chore: UI arrow left add RTL. * chore: fix appearence page RTL * chore: add margins two-factor RTL * chore: update due to review --------- Co-authored-by: Peer Richelsen --- apps/web/pages/settings/security/two-factor-auth.tsx | 4 ++-- packages/features/settings/layouts/SettingsLayout.tsx | 2 +- packages/ui/components/form/color-picker/colorpicker.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/pages/settings/security/two-factor-auth.tsx b/apps/web/pages/settings/security/two-factor-auth.tsx index 5d8f6373cf..811f9d2d03 100644 --- a/apps/web/pages/settings/security/two-factor-auth.tsx +++ b/apps/web/pages/settings/security/two-factor-auth.tsx @@ -46,10 +46,10 @@ const TwoFactorAuthView = () => { user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) } /> -
+

{t("two_factor_auth")}

- + {user?.twoFactorEnabled ? t("enabled") : t("disabled")}
diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index c8c39c427d..5490560d6f 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -130,7 +130,7 @@ const BackButtonInSidebar = ({ name }: { name: string }) => { href="/" className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-64 flex-row items-center rounded-md py-2 px-3 text-sm font-medium leading-4" data-testid={`vertical-tab-${name}`}> - + {name} diff --git a/packages/ui/components/form/color-picker/colorpicker.tsx b/packages/ui/components/form/color-picker/colorpicker.tsx index b8088b50a2..e1e473ae3b 100644 --- a/packages/ui/components/form/color-picker/colorpicker.tsx +++ b/packages/ui/components/form/color-picker/colorpicker.tsx @@ -20,7 +20,7 @@ const ColorPicker = (props: ColorPickerProps) => { return (
-
+