From 12466fb0640cc449d3e6927b9b9c8c7f8307fd8a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 3 Feb 2024 03:04:13 +0530 Subject: [PATCH] feat: New Organization Schema along with the ability to invite existing users to an organization (#13002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add profile table Copy to profile table Update schema Migrations * wip * Get switcher working * Add organization seeder * wip * Remove dead code * Fix unit tests * relevantProfile -> profile * cache bust * One more comment * Squash migrations * Copy to profile table * Update schema * Fix existing user invite flow * Add profile table * Copy to profile table * Update schema * Format * Add profile table Copy to profile table Update schema Migrations * Format * wip Get switcher working Add organization seeder wip Remove dead code Fix unit tests relevantProfile -> profile cache bust One more comment Squash migrations Copy to profile table Update schema Fix existing user invite flow Add profile table Copy to profile table Update schema chore: small improvements to signup (#13227) * removed features on mobile, better background color * nit * dark mode nits New Crowdin translations by Github Action fix: event reschedule workflow trigger (#13122) * fix event reschedule workflow trigger * uncomment previously failed test * review fix --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: CarinaWolli chore: added community support to help (#13213) refactor: Abstract `handleSeats` from `handleNewBooking` (#12062) * Refactor createBooking * Type fix * Abstract handleSeats * Create Invitee type * Create OrganizerUser type * Abstract addVideoCallDataToEvt * Abstract createLoggerWithEventDetails * Abstract `handleAppStatus` from handler * Create ReqAppsStatus type * Move `deleteMeeting` and `getCalendar` * Set parameters for `handleSeats` * Typescript refactor * Change function params from req * Type fix --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Erik fix: Dynamic group booking org-migration redirect (#13223) * fix: Dynamic group booking redirect * Fix flaky private team test test: Check availability tab and all your functions (teste2e-availability) (#12434) * add changes * add changes * add changes * add changes * Requested changes * Change way to grab element --------- Co-authored-by: gitstart-calcom Co-authored-by: gitstart-calcom Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Keith Williams Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> fix: Reschedule a cancelled booking (#13170) * fix: Reschedule a cancelled booking * canceled instead of cancelled --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> v3.7.0 test: In Team Event type when location is Organizer Default App and U… (#12939) * test: In Team Event type when location is Organizer Default App and User Metadata is emoty * chore: use variable * fix: type error test: add test for invalid cal video (#13103) * test: add test for invalid cal video * chore: add confirm * chore: fix type error * chore: update code * test: add unit test * chore: type error * chore * chore: type * chore: fix test * chore --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> chore: add error message for no availability (#13230) * chore: add error message for no default user availability * chore: check only availability * chore: change message * chore: add eventType fix: Team revert with members (#13228) refactor: Refactor seats logic (#12905) * Refactor createBooking * Type fix * Abstract handleSeats * Create Invitee type * Create OrganizerUser type * Abstract addVideoCallDataToEvt * Abstract createLoggerWithEventDetails * Abstract `handleAppStatus` from handler * Create ReqAppsStatus type * Move `deleteMeeting` and `getCalendar` * Set parameters for `handleSeats` * Typescript refactor * Change function params from req * Type fix * Move handleSeats * Abstract lastAttendeeDeleteBooking * Create function for rescheduling seated events * Fix imports on reschedule seats function * Fix imports * Import handleSeats function * Fix rescheduleUid type * Refactor owner reschedule to new time slot * Refactor combine two booking times together * Reschedule as an attendee * Refactor createNewSeat * Remove old handleSeats * Remove lastAttendeeDeleteBooking from handleNewBooking * Test for new attendee right params are passed * Unit test params for reschedule * Typo fix * Clean up * Create new seat test * Test when attendee already signs up for booking * Type fix * Test reschedule move attendee to existing booking * On reschedule create new booking * Test on last attendee cancel booking * Owner reschedule to new time slot * Owner rescheduling, merge two bookings together * Test: when merging more than available seats, then fail * Test: fail when event is full * Remove duplicate E2E tests * Clean up * Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent` * Refactor `calcAppsStatus` * Assign `evt` to resutl of `addVideoCallDataToEvent` * Use prisma.transaction when moving attendees * Clean create seat call * Use ErrorCode enum * Use attendeeRescheduledSeatedBooking function * Await function * Prevent double triggering of workflows * Use inviteeToAdd in createNewSeat * Remove unused error code * Remove old handleSeats file * Type fix * Type fix * Type fix * Type fix * Type fix * Type fix * Type fix * Type fix --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Erik feat: Allow admins to filter bookings only by attendee emails (#13236) * chore: Allow admins to filter bookings only by attendee emails * Fix type error Fixed [CAL-2930] Org branding isnt centered in shell #13225 (#13237) Add profile table Copy to profile table Update schema Migrations Format * wip * Add profile relation everywhere * Fix schema * Fix invite handler * Add profileId during eventTypec reation * Get dashboard working with profile relatioon * Add workflow create repository * Add profileId to host * Add profileId to credential table * Add destinationCalendar repository * Add profileId to membership * Revert all profile related changes * Add acme and dunder * Fix membership profileId usgae * Various fixes * Some renaming * Fix failing tests * Debugging next-aut * Consolidate migrations * Testing fixes * Fix unit tests * Self review feedback * PR feedback * Fix orgMigration as well * Add profile schema and copy users to it * Remove migrations from here * Fix type issues * Fix type error after main merge * Remove movedFromUserId * Add missing migration file * Self review addressed * Joe's review addressed * Fix profile switcher --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .../steps-views/UserProfile.tsx | 11 +- apps/web/components/team/screens/Team.tsx | 9 +- .../d/[link]/[slug]/getServerSideProps.tsx | 17 +- apps/web/lib/orgMigration.test.ts | 7 +- apps/web/lib/orgMigration.ts | 40 +- .../lib/team/[slug]/getServerSideProps.tsx | 2 + .../views/event-types-listing-view.tsx | 13 +- .../users-public-view.getServerSideProps.tsx | 93 ++-- .../modules/users/views/users-public-view.tsx | 12 +- ...rs-type-public-view.getServerSideProps.tsx | 36 +- .../videos-single-view.getServerSideProps.tsx | 21 +- apps/web/pages/api/link.ts | 10 + apps/web/pages/api/user/avatar.ts | 22 +- .../web/pages/settings/my-account/profile.tsx | 15 +- apps/web/playwright/fixtures/orgs.ts | 2 +- apps/web/playwright/fixtures/users.ts | 118 ++++- .../organization-invitation.e2e.ts | 2 +- apps/web/public/static/locales/en/common.json | 1 + .../test/handlers/requestReschedule.test.ts | 15 +- .../web/test/lib/availabilityAsString.test.ts | 5 + .../test/lib/handleChildrenEventTypes.test.ts | 10 + .../utils/bookingScenario/bookingScenario.ts | 25 +- .../getMockRequestDataForBooking.ts | 1 + .../bookingScenario/getSampleUserInSession.ts | 3 + packages/app-store/_utils/installation.ts | 10 +- packages/app-store/amie/api/add.ts | 2 +- packages/app-store/around/api/_getAdd.ts | 2 +- packages/app-store/cal-ai/api/_getCallback.ts | 2 +- packages/app-store/campfire/api/add.ts | 2 +- packages/app-store/cron/api/add.ts | 2 +- packages/app-store/deel/api/add.ts | 2 +- packages/app-store/discord/api/add.ts | 2 +- packages/app-store/eightxeight/api/add.ts | 2 +- packages/app-store/element-call/api/add.ts | 2 +- packages/app-store/facetime/api/add.ts | 2 +- packages/app-store/fathom/api/add.ts | 2 +- packages/app-store/ga4/api/add.ts | 2 +- packages/app-store/gtm/api/add.ts | 2 +- packages/app-store/make/api/add.ts | 2 +- packages/app-store/matomo/api/add.ts | 2 +- packages/app-store/metapixel/api/add.ts | 2 +- packages/app-store/mirotalk/api/add.ts | 2 +- .../app-store/mock-payment-app/api/add.ts | 2 +- packages/app-store/n8n/api/add.ts | 2 +- packages/app-store/ping/api/_getAdd.ts | 2 +- packages/app-store/pipedream/api/add.ts | 2 +- packages/app-store/pipedrive-crm/api/add.ts | 7 +- packages/app-store/plausible/api/add.ts | 2 +- packages/app-store/qr_code/api/add.ts | 2 +- packages/app-store/raycast/api/add.ts | 2 +- packages/app-store/riverside/api/_getAdd.ts | 2 +- packages/app-store/roam/api/add.ts | 2 +- .../pages/router/getServerSideProps.ts | 6 +- .../pages/routing-link/getServerSideProps.ts | 9 +- .../routing-forms/trpc/forms.handler.ts | 4 +- packages/app-store/salesroom/api/add.ts | 2 +- packages/app-store/shimmervideo/api/add.ts | 2 +- packages/app-store/signal/api/add.ts | 2 +- packages/app-store/sirius_video/api/add.ts | 2 +- packages/app-store/skiff/api/add.ts | 2 +- packages/app-store/telegram/api/add.ts | 2 +- packages/app-store/templates/basic/api/add.ts | 2 +- .../templates/booking-pages-tag/api/add.ts | 2 +- .../templates/event-type-app-card/api/add.ts | 2 +- .../api/add.ts | 2 +- .../templates/general-app-settings/api/add.ts | 2 +- .../templates/link-as-an-app/api/add.ts | 2 +- packages/app-store/typeform/api/add.ts | 2 +- packages/app-store/vimcal/api/add.ts | 2 +- .../weather_in_your_calendar/api/add.ts | 2 +- packages/app-store/whatsapp/api/add.ts | 2 +- packages/app-store/whereby/api/_getAdd.ts | 2 +- packages/app-store/wordpress/api/add.ts | 2 +- .../src/templates/OrganizerRequestEmail.tsx | 1 + .../emails/src/templates/TeamInviteEmail.tsx | 36 +- .../emails/templates/team-invite-email.ts | 4 + .../features/auth/lib/getServerSession.ts | 31 +- .../auth/lib/next-auth-custom-adapter.ts | 16 - .../features/auth/lib/next-auth-options.ts | 188 ++++--- .../auth/signup/handlers/calcomHandler.ts | 7 +- .../auth/signup/handlers/selfHostedHandler.ts | 7 +- packages/features/auth/signup/username.ts | 6 +- .../signup/utils/createOrUpdateMemberships.ts | 31 +- .../signup/utils/getOrgUsernameFromEmail.ts | 2 +- .../auth/signup/utils/organization.ts | 56 +- .../bookings/components/BookerSeo.tsx | 4 +- .../features/bookings/lib/handleNewBooking.ts | 87 ++- .../test/fresh-booking.test.ts | 2 + .../lib/ImpersonationProvider.ts | 57 +- .../lib/handleChildrenEventTypes.ts | 4 + .../pages/components/MemberListItem.tsx | 2 +- .../settings/other-team-members-view.tsx | 11 +- .../ee/teams/components/MemberListItem.tsx | 8 +- .../ee/teams/components/TeamListItem.tsx | 13 +- .../ee/teams/components/TeamsListing.tsx | 44 +- .../ee/teams/lib/getUserAdminTeams.ts | 7 +- packages/features/ee/teams/lib/payments.ts | 6 +- .../ee/users/components/UsersTable.tsx | 1 + .../features/eventtypes/lib/getPublicEvent.ts | 178 ++++--- .../filters/components/TeamsFilter.tsx | 18 +- .../filters/lib/getTeamsFiltersFromQuery.ts | 16 +- packages/features/shell/Shell.tsx | 128 ++++- .../components/AvailabilitySliderTable.tsx | 9 +- packages/lib/OgImages.tsx | 2 +- packages/lib/constants.ts | 2 +- .../lib/createAProfileForAnExistingUser.ts | 82 +++ packages/lib/defaultEvents.ts | 1 - packages/lib/getAvatarUrl.ts | 12 +- packages/lib/getBookerUrl/server.ts | 4 +- packages/lib/getEventTypeById.ts | 55 +- packages/lib/server/checkRegularUsername.ts | 24 +- packages/lib/server/getUsersCredentials.ts | 5 +- packages/lib/server/queries/teams/index.ts | 49 +- packages/lib/server/repository/eventType.ts | 134 +++++ packages/lib/server/repository/membership.ts | 108 ++++ packages/lib/server/repository/profile.ts | 500 ++++++++++++++++++ packages/lib/server/repository/team.ts | 15 + packages/lib/server/repository/teamUtils.ts | 16 + packages/lib/server/repository/user.ts | 336 ++++++++++++ packages/lib/test/builder.ts | 2 + .../migration.sql | 65 +++ packages/prisma/schema.prisma | 63 ++- packages/prisma/seed.ts | 378 ++++++++++++- packages/prisma/selects/user.ts | 1 - packages/prisma/zod-utils.ts | 2 +- .../server/middlewares/sessionMiddleware.ts | 114 ++-- .../routers/loggedInViewer/appById.handler.ts | 2 +- .../loggedInViewer/integrations.handler.ts | 2 +- .../routers/loggedInViewer/me.handler.ts | 24 +- .../setDestinationCalendar.handler.ts | 2 +- .../teamsAndUserProfilesQuery.handler.ts | 8 +- ...pdateUserDefaultConferencingApp.handler.ts | 2 +- .../loggedInViewer/workflowOrder.handler.ts | 4 +- .../viewer/admin/listPaginated.handler.ts | 1 - .../team/listTeamAvailability.handler.ts | 27 +- .../viewer/bookings/confirm.handler.ts | 2 +- .../viewer/bookings/editLocation.handler.ts | 2 +- .../bookings/requestReschedule.handler.ts | 6 +- .../viewer/eventTypes/create.handler.ts | 10 +- .../viewer/eventTypes/duplicate.handler.ts | 3 +- .../routers/viewer/eventTypes/get.handler.ts | 1 + .../viewer/eventTypes/getByViewer.handler.ts | 379 ++++++------- .../viewer/eventTypes/getByViewer.schema.ts | 2 +- .../server/routers/viewer/eventTypes/types.ts | 1 + .../viewer/eventTypes/update.handler.ts | 12 +- .../organizations/adminVerify.handler.ts | 17 +- .../organizations/bulkDeleteUsers.handler.ts | 9 +- .../viewer/organizations/create.handler.ts | 93 ++-- .../organizations/createTeams.handler.ts | 9 +- .../viewer/organizations/getUser.handler.ts | 2 +- .../organizations/listMembers.handler.ts | 48 +- .../listOtherTeamMembers.handler.ts | 35 +- .../organizations/listOtherTeams.handler.ts | 4 +- .../trpc/server/routers/viewer/slots/util.ts | 32 +- .../server/routers/viewer/teams/_router.tsx | 3 +- .../viewer/teams/acceptOrLeave.handler.ts | 48 +- .../routers/viewer/teams/create.handler.ts | 32 +- .../routers/viewer/teams/get.handler.ts | 1 + .../teams/getMemberAvailability.handler.ts | 5 - .../inviteMember/inviteMember.handler.ts | 259 ++++++--- .../inviteMember/inviteMemberUtils.test.ts | 243 ++++++--- .../viewer/teams/inviteMember/utils.ts | 239 ++++++--- .../routers/viewer/teams/list.handler.ts | 6 +- .../routers/viewer/teams/list.schema.ts | 10 +- .../viewer/teams/listMembers.handler.ts | 5 +- .../routers/viewer/teams/publish.handler.ts | 8 +- .../viewer/teams/removeMember.handler.ts | 69 ++- .../viewer/teams/resendInvitation.handler.ts | 1 + .../viewer/webhook/getByViewer.handler.ts | 3 +- .../getWorkflowActionOptions.handler.ts | 2 +- .../viewer/workflows/update.handler.ts | 2 +- packages/types/UserProfile.d.ts | 36 ++ packages/types/next-auth.d.ts | 7 + .../ui/components/avatar/UserAvatar.test.tsx | 28 +- packages/ui/components/avatar/UserAvatar.tsx | 22 +- .../ui/components/avatar/UserAvatarGroup.tsx | 12 +- .../avatar/UserAvatarGroupWithOrg.tsx | 6 +- turbo.json | 3 +- 178 files changed, 4091 insertions(+), 1262 deletions(-) create mode 100644 packages/lib/createAProfileForAnExistingUser.ts create mode 100644 packages/lib/server/repository/eventType.ts create mode 100644 packages/lib/server/repository/membership.ts create mode 100644 packages/lib/server/repository/profile.ts create mode 100644 packages/lib/server/repository/teamUtils.ts create mode 100644 packages/lib/server/repository/user.ts create mode 100644 packages/prisma/migrations/20240130134919_add_profile_table_and_add_event_type_relation/migration.sql create mode 100644 packages/types/UserProfile.d.ts diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index 54ff76fc1f..84bb8d44c4 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -8,7 +8,6 @@ import { md } from "@calcom/lib/markdownIt"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import turndown from "@calcom/lib/turndownService"; import { trpc } from "@calcom/trpc/react"; -import type { Ensure } from "@calcom/types/utils"; import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; import { UserAvatar } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; @@ -97,18 +96,10 @@ const UserProfile = () => { }, ]; - const organization = - user.organization && user.organization.id - ? { - ...(user.organization as Ensure), - slug: user.organization.slug || null, - requestedSlug: user.organization.metadata?.requestedSlug || null, - } - : null; return (
- {user && } + {user && } , "inviteToken">; type MembersType = TeamType["members"]; -type MemberType = Pick & { +type MemberType = Pick< + MembersType[number], + "id" | "name" | "bio" | "username" | "organizationId" | "avatarUrl" +> & { + profile: Omit; safeBio: string | null; bookerUrl: string; }; @@ -27,7 +32,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n key={member.id} href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
- +

{member.name}

diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx index cea6dc7492..12ab695f03 100644 --- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx @@ -5,6 +5,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; @@ -56,19 +57,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) { return notFound; } - const user = await prisma.user.findFirst({ - where: { - username, - organization: isValidOrgDomain - ? { - slug: currentOrgDomain, - } - : null, - }, - select: { - away: true, - hideBranding: true, - }, + const [user] = await UserRepository.findUsersByUsername({ + usernameList: [username], + orgSlug: org, }); if (!user) { diff --git a/apps/web/lib/orgMigration.test.ts b/apps/web/lib/orgMigration.test.ts index 080ac4131d..7855506d12 100644 --- a/apps/web/lib/orgMigration.test.ts +++ b/apps/web/lib/orgMigration.test.ts @@ -1589,17 +1589,20 @@ async function createTeamOutsideOrg( }); } -async function createUserOutsideOrg(data: Omit) { +async function createUserOutsideOrg( + data: Omit +) { return await prismock.user.create({ data: { ...data, + movedToProfileId: null, organizationId: null, }, }); } async function createUserInsideTheOrg( - data: Omit, + data: Omit, orgId: number ) { const org = await prismock.team.findUnique({ diff --git a/apps/web/lib/orgMigration.ts b/apps/web/lib/orgMigration.ts index c09304c315..594943324c 100644 --- a/apps/web/lib/orgMigration.ts +++ b/apps/web/lib/orgMigration.ts @@ -3,6 +3,7 @@ import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomai import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import prisma from "@calcom/prisma"; import type { Team, User } from "@calcom/prisma/client"; import { RedirectType } from "@calcom/prisma/client"; @@ -327,7 +328,7 @@ async function setOrgSlugIfNotSet( slug: string | null; }, orgMetadata: { - requestedSlug?: string | undefined; + requestedSlug?: string | null | undefined; }, targetOrgId: number ) { @@ -348,7 +349,7 @@ async function setOrgSlugIfNotSet( function assertUserPartOfOrgAndRemigrationAllowed( userToMoveToOrg: { - organizationId: User["organizationId"]; + organizationId: number | null; }, targetOrgId: number, targetOrgUsername: string, @@ -384,7 +385,7 @@ async function getTeamOrThrowError(targetOrgId: number) { function assertUserPartOfOtherOrg( userToMoveToOrg: { - organizationId: User["organizationId"]; + organizationId: number | null; } | null, userName: string | undefined, userId: number | undefined, @@ -594,6 +595,35 @@ async function dbMoveUserToOrg({ }, }, }); + + await prisma.profile.upsert({ + create: { + uid: ProfileRepository.generateProfileUid(), + userId: userToMoveToOrg.id, + organizationId: targetOrgId, + username: targetOrgUsername, + movedFromUser: { + connect: { + id: userToMoveToOrg.id, + }, + }, + }, + update: { + organizationId: targetOrgId, + username: targetOrgUsername, + movedFromUser: { + connect: { + id: userToMoveToOrg.id, + }, + }, + }, + where: { + userId_organizationId: { + userId: userToMoveToOrg.id, + organizationId: targetOrgId, + }, + }, + }); } async function moveTeamsWithoutMembersToOrg({ @@ -841,6 +871,10 @@ async function dbRemoveUserFromOrg({ }, }, }); + + await ProfileRepository.deleteMany({ + userIds: [userToRemoveFromOrg.id], + }); } async function removeMembership({ diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx index b3670f8511..76967e87c2 100644 --- a/apps/web/lib/team/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx @@ -121,7 +121,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { name: member.name, id: member.id, + avatarUrl: member.avatarUrl, bio: member.bio, + profile: member.profile, subteams: member.subteams, username: member.username, accepted: member.accepted, diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index c166ee4254..192783d562 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -729,9 +729,9 @@ const EventTypeListHeading = ({ return (
@@ -739,7 +739,7 @@ const EventTypeListHeading = ({ - {profile?.name || ""} + {profile.name || ""} {membershipCount && teamId && ( @@ -751,13 +751,13 @@ const EventTypeListHeading = ({ )} - {profile?.slug && ( + {profile.slug && ( {`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`} )}
- {!profile?.slug && !!teamId && ( + {!profile.slug && !!teamId && ( + + + + { + setMenuOpen(false); + }} + className="min-w-56 hariom group overflow-hidden rounded-md"> + + Switch to + + {options.map((option) => { + const isSelected = currentOption.value === option.value; + return ( + { + setMenuOpen(false); + if (isSelected) return; + update({ + upId: option.value, + }).then(() => { + window.location.reload(); + }); + }} + className={classNames("flex w-full", isSelected ? "bg-subtle text-emphasis" : "")}> + + + + {option.label} + + {isSelected ? + + ); + })} + + {/* */} + + + + ); +} diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index e1865f02be..235fd4d4c1 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -9,6 +9,7 @@ import type { DateRange } from "@calcom/lib/date-ranges"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { Button, ButtonGroup, DataTable } from "@calcom/ui"; import { UserAvatar } from "@calcom/ui"; @@ -22,11 +23,13 @@ export interface SliderUser { username: string | null; name: string | null; organizationId: number; + avatarUrl: string | null; email: string; timeZone: string; role: MembershipRole; defaultScheduleId: number | null; dateRanges: DateRange[]; + profile: UserProfile; } function UpgradeTeamTip() { @@ -57,7 +60,6 @@ function UpgradeTeamTip() { } export function AvailabilitySliderTable() { - const { t } = useLocale(); const tableContainerRef = useRef(null); const [browsingDate, setBrowsingDate] = useState(dayjs()); const [editSheetOpen, setEditSheetOpen] = useState(false); @@ -83,7 +85,7 @@ export function AvailabilitySliderTable() { accessorFn: (data) => data.email, header: "Member", cell: ({ row }) => { - const { username, email, timeZone, name, organizationId } = row.original; + const { username, email, timeZone, name, avatarUrl, profile } = row.original; return (
diff --git a/packages/lib/OgImages.tsx b/packages/lib/OgImages.tsx index ce3efbbc75..74d0ae36e7 100644 --- a/packages/lib/OgImages.tsx +++ b/packages/lib/OgImages.tsx @@ -117,7 +117,7 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => { // Users ALWAYS have an image (albeit a gray empty person avatar), so this mainly filters out // any non existing images for dynamic collectives, while at the same time removing them from // the names list, because the profile name of that event is a concatenation of all names. - const attendees = (profile?.image ? [profile, ...users] : users).filter( + const attendees = (profile.image ? [profile, ...users] : users).filter( (value, index, self) => self.findIndex((v) => v.name === value.name) == index ); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 70a6a2ba99..cc25a5b303 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -91,7 +91,7 @@ export const IS_STRIPE_ENABLED = !!( export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES; export const FULL_NAME_LENGTH_MAX_LIMIT = 50; export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; - +export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1"; // Needed for orgs export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[]; export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[]; diff --git a/packages/lib/createAProfileForAnExistingUser.ts b/packages/lib/createAProfileForAnExistingUser.ts new file mode 100644 index 0000000000..1d2579e297 --- /dev/null +++ b/packages/lib/createAProfileForAnExistingUser.ts @@ -0,0 +1,82 @@ +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; +import { RedirectType } from "@calcom/prisma/enums"; + +import { getOrgUsernameFromEmail } from "../features/auth/signup/utils/getOrgUsernameFromEmail"; +import logger from "./logger"; +import { safeStringify } from "./safeStringify"; + +const log = logger.getSubLogger({ prefix: ["lib", "createAProfileForAnExistingUser"] }); +export const createAProfileForAnExistingUser = async ({ + user, + organizationId, +}: { + user: { + email: string; + id: number; + username: string | null; + }; + organizationId: number; +}) => { + const org = await TeamRepository.findById({ id: organizationId }); + if (!org) { + throw new Error(`Organization with id ${organizationId} not found`); + } + const usernameInOrg = getOrgUsernameFromEmail(user.email, org.metadata.orgAutoAcceptEmail ?? null); + const profile = await ProfileRepository.createForExistingUser({ + userId: user.id, + organizationId, + username: usernameInOrg, + email: user.email, + movedFromUserId: user.id, + }); + + await UserRepository.updateWhereId({ + whereId: user.id, + data: { + movedToProfileId: profile.id, + }, + }); + + log.debug( + "Created profile for user", + safeStringify({ userId: user.id, profileId: profile.id, usernameInOrg, username: user.username }) + ); + + const orgSlug = org.slug || org.requestedSlug; + + if (!orgSlug) { + throw new Error(`Organization with id ${organizationId} doesn't have a slug`); + } + + const orgUrl = getOrgFullOrigin(orgSlug); + + if (user.username) { + log.debug(`Creating redirect for user ${user.username} to ${orgUrl}/${usernameInOrg}`); + await prisma.tempOrgRedirect.upsert({ + where: { + from_type_fromOrgId: { + from: user.username, + type: RedirectType.User, + fromOrgId: 0, + }, + }, + update: { + type: RedirectType.User, + from: user.username, + fromOrgId: 0, + toUrl: `${orgUrl}/${usernameInOrg}`, + }, + create: { + type: RedirectType.User, + from: user.username, + fromOrgId: 0, + toUrl: `${orgUrl}/${usernameInOrg}`, + }, + }); + } + return profile; +}; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index b0da09d6e5..3e507f3172 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -49,7 +49,6 @@ const user: User & { credentials: CredentialPayload[] } = { darkBrandColor: "#efefef", allowDynamicBooking: true, timeFormat: 12, - organizationId: null, }; const customInputs: CustomInputSchema[] = []; diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts index 3ef573c63f..f4f1f0fa2d 100644 --- a/packages/lib/getAvatarUrl.ts +++ b/packages/lib/getAvatarUrl.ts @@ -1,21 +1,27 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import type { User, Team } from "@calcom/prisma/client"; +import type { UserProfile } from "@calcom/types/UserProfile"; /** * Gives an organization aware avatar url for a user * It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed */ export const getUserAvatarUrl = ( - user: (Pick & { avatarUrl?: string | null }) | undefined + user: + | (Pick & { + profile: Omit; + avatarUrl: string | null; + }) + | undefined ) => { if (user?.avatarUrl) { return user.avatarUrl; } if (!user?.username) return AVATAR_FALLBACK; // avatar.png automatically redirects to fallback avatar if user doesn't have one - return `${WEBAPP_URL}/${user.username}/avatar.png${ - user.organizationId ? `?orgId=${user.organizationId}` : "" + return `${WEBAPP_URL}/${user.profile?.username}/avatar.png${ + user.profile?.organizationId ? `?orgId=${user.profile.organizationId}` : "" }`; }; diff --git a/packages/lib/getBookerUrl/server.ts b/packages/lib/getBookerUrl/server.ts index 0031fa7943..551bff6540 100644 --- a/packages/lib/getBookerUrl/server.ts +++ b/packages/lib/getBookerUrl/server.ts @@ -1,8 +1,8 @@ import { CAL_URL } from "../constants"; import { getBrand } from "../server/getBrand"; -export const getBookerBaseUrl = async (user: { organizationId: number | null }) => { - const orgBrand = await getBrand(user.organizationId); +export const getBookerBaseUrl = async (organizationId: number | null) => { + const orgBrand = await getBrand(organizationId); return orgBrand?.fullDomain ?? CAL_URL; }; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 3aafbb37bc..c376b9428c 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -7,6 +7,7 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/ import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; import { SchedulingType, MembershipRole } from "@calcom/prisma/enums"; import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -22,9 +23,11 @@ interface getEventTypeByIdProps { prisma: PrismaClient; isTrpcCall?: boolean; isUserOrganizationAdmin: boolean; + currentOrganizationId: number | null; } export default async function getEventTypeById({ + currentOrganizationId, eventTypeId, userId, prisma, @@ -33,10 +36,10 @@ export default async function getEventTypeById({ }: getEventTypeByIdProps) { const userSelect = Prisma.validator()({ name: true, + avatarUrl: true, username: true, id: true, email: true, - organizationId: true, locale: true, defaultScheduleId: true, }); @@ -111,7 +114,7 @@ export default async function getEventTypeById({ bookingFields: true, owner: { select: { - organizationId: true, + id: true, }, }, parent: { @@ -171,11 +174,11 @@ export default async function getEventTypeById({ select: { owner: { select: { + avatarUrl: true, name: true, username: true, email: true, id: true, - organizationId: true, }, }, hidden: true, @@ -245,6 +248,36 @@ export default async function getEventTypeById({ const newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {}; const apps = newMetadata?.apps || {}; const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata }; + const eventTeamMembershipsWithUserProfile = []; + for (const eventTeamMembership of rawEventType.team?.members || []) { + eventTeamMembershipsWithUserProfile.push({ + ...eventTeamMembership, + user: await UserRepository.enrichUserWithItsProfile({ + user: eventTeamMembership.user, + }), + }); + } + + const childrenWithUserProfile = []; + for (const child of rawEventType.children || []) { + childrenWithUserProfile.push({ + ...child, + owner: child.owner + ? await UserRepository.enrichUserWithItsProfile({ + user: child.owner, + }) + : null, + }); + } + + const eventTypeUsersWithUserProfile = []; + for (const eventTypeUser of rawEventType.users) { + eventTypeUsersWithUserProfile.push( + await UserRepository.enrichUserWithItsProfile({ + user: eventTypeUser, + }) + ); + } newMetadata.apps = { ...apps, @@ -267,11 +300,11 @@ export default async function getEventTypeById({ customInputs: parsedCustomInputs, users: rawEventType.users, bookerUrl: restEventType.team - ? await getBookerBaseUrl({ organizationId: restEventType.team.parentId }) + ? await getBookerBaseUrl(restEventType.team.parentId) : restEventType.owner - ? await getBookerBaseUrl(restEventType.owner) + ? await getBookerBaseUrl(currentOrganizationId) : CAL_URL, - children: restEventType.children.flatMap((ch) => + children: childrenWithUserProfile.flatMap((ch) => ch.owner !== null ? { ...ch, @@ -312,12 +345,11 @@ export default async function getEventTypeById({ eventType.users.push(fallbackUser); } - const eventTypeUsers: ((typeof eventType.users)[number] & { avatar: string })[] = eventType.users.map( - (user) => ({ + const eventTypeUsers: ((typeof eventType.users)[number] & { avatar: string })[] = + eventTypeUsersWithUserProfile.map((user) => ({ ...user, avatar: getUserAvatarUrl(user), - }) - ); + })); const currentUser = eventType.users.find((u) => u.id === userId); @@ -356,7 +388,7 @@ export default async function getEventTypeById({ const isOrgEventType = !!eventTypeObject.team?.parentId; const teamMembers = eventTypeObject.team - ? eventTypeObject.team.members + ? eventTeamMembershipsWithUserProfile .filter((member) => member.accepted || isOrgEventType) .map((member) => { const user: typeof member.user & { avatar: string } = { @@ -365,6 +397,7 @@ export default async function getEventTypeById({ }; return { ...user, + profileId: user.profile.id, eventTypes: user.eventTypes.map((evTy) => evTy.slug), membership: member.role, }; diff --git a/packages/lib/server/checkRegularUsername.ts b/packages/lib/server/checkRegularUsername.ts index 991953436f..89e61810ea 100644 --- a/packages/lib/server/checkRegularUsername.ts +++ b/packages/lib/server/checkRegularUsername.ts @@ -1,23 +1,19 @@ import slugify from "@calcom/lib/slugify"; -import prisma from "@calcom/prisma"; + +import { ProfileRepository } from "./repository/profile"; export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) { const username = slugify(_username); const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5; - const user = await prisma.user.findFirst({ - where: { - username, - organization: currentOrgDomain - ? { - slug: currentOrgDomain, - } - : null, - }, - select: { - username: true, - }, - }); + const profiles = currentOrgDomain + ? await ProfileRepository.findManyByOrgSlugOrRequestedSlug({ + orgSlug: currentOrgDomain, + usernames: [username], + }) + : null; + + const user = profiles ? profiles[0].user : null; if (user) { return { diff --git a/packages/lib/server/getUsersCredentials.ts b/packages/lib/server/getUsersCredentials.ts index f4f1dfb493..d547b0378f 100644 --- a/packages/lib/server/getUsersCredentials.ts +++ b/packages/lib/server/getUsersCredentials.ts @@ -1,10 +1,11 @@ import { prisma } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -export async function getUsersCredentials(userId: number) { +export async function getUsersCredentials(user: NonNullable) { const credentials = await prisma.credential.findMany({ where: { - userId, + userId: user.id, }, select: credentialForCalendarServiceSelect, orderBy: { diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 7930aad051..032841a708 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client"; import { getAppFromSlug } from "@calcom/app-store/utils"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; +import type { Team } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { _EventTypeModel } from "@calcom/prisma/zod"; import { @@ -13,6 +14,7 @@ import { import { WEBAPP_URL } from "../../../constants"; import { getBookerBaseUrlSync } from "../../../getBookerUrl/client"; import { getTeam, getOrg } from "../../repository/team"; +import { UserRepository } from "../../repository/user"; export type TeamWithMembers = Awaited>; @@ -23,12 +25,13 @@ export async function getTeamWithMembers(args: { orgSlug?: string | null; includeTeamLogo?: boolean; isTeamView?: boolean; + currentOrg?: Team | null; /** * If true, means that you are fetching an organization and not a team */ isOrgView?: boolean; }) { - const { id, slug, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args; + const { id, slug, currentOrg, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args; // This should improve performance saving already app data found. const appDataMap = new Map(); @@ -36,14 +39,9 @@ export async function getTeamWithMembers(args: { username: true, email: true, name: true, + avatarUrl: true, id: true, bio: true, - organizationId: true, - organization: { - select: { - slug: true, - }, - }, teams: { select: { team: { @@ -150,11 +148,24 @@ export async function getTeamWithMembers(args: { if (!teamOrOrg) return null; - const members = teamOrOrg.members.map((m) => { - const { credentials, ...restUser } = m.user; + const teamOrOrgMemberships = []; + for (const membership of teamOrOrg.members) { + teamOrOrgMemberships.push({ + ...membership, + user: await UserRepository.enrichUserWithItsProfile({ + user: membership.user, + }), + }); + } + const members = teamOrOrgMemberships.map((m) => { + const { credentials, profile, ...restUser } = m.user; return { ...restUser, + username: profile?.username ?? restUser.username, role: m.role, + profile: profile, + organizationId: profile?.organizationId ?? null, + organization: profile?.organization, accepted: m.accepted, disableImpersonation: m.disableImpersonation, subteams: orgSlug @@ -163,7 +174,7 @@ export async function getTeamWithMembers(args: { .map((membership) => membership.team.slug) : null, avatar: `${WEBAPP_URL}/${m.user.username}/avatar.png`, - bookerUrl: getBookerBaseUrlSync(m.user.organization?.slug || ""), + bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""), connectedApps: !isTeamView ? credentials?.map((cred) => { const appSlug = cred.app?.slug; @@ -187,10 +198,26 @@ export async function getTeamWithMembers(args: { }; }); - const eventTypes = teamOrOrg.eventTypes.map((eventType) => ({ + const eventTypesWithUsersUserProfile = []; + for (const eventType of teamOrOrg.eventTypes) { + const usersWithUserProfile = []; + for (const user of eventType.users) { + usersWithUserProfile.push( + await UserRepository.enrichUserWithItsProfile({ + user, + }) + ); + } + eventTypesWithUsersUserProfile.push({ + ...eventType, + users: usersWithUserProfile, + }); + } + const eventTypes = eventTypesWithUsersUserProfile.map((eventType) => ({ ...eventType, metadata: EventTypeMetaDataSchema.parse(eventType.metadata), })); + // Don't leak invite tokens to the frontend const { inviteTokens, ...teamWithoutInviteTokens } = teamOrOrg; diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts new file mode 100644 index 0000000000..1faa8eb0bc --- /dev/null +++ b/packages/lib/server/repository/eventType.ts @@ -0,0 +1,134 @@ +import type { Prisma, EventType as PrismaEventType } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import type { Ensure } from "@calcom/types/utils"; + +import { safeStringify } from "../../safeStringify"; +import { LookupTarget, ProfileRepository } from "./profile"; + +const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); +type NotSupportedProps = "locations"; +type IEventType = Ensure< + Partial< + Omit & { + userId: PrismaEventType["userId"]; + profileId: PrismaEventType["profileId"]; + teamId: PrismaEventType["teamId"]; + parentId: PrismaEventType["parentId"]; + scheduleId: PrismaEventType["scheduleId"]; + } + >, + "title" | "slug" | "length" +>; + +export class EventTypeRepository { + static async create(data: IEventType) { + const { + userId, + profileId, + teamId, + parentId, + scheduleId, + bookingLimits, + recurringEvent, + metadata, + bookingFields, + durationLimits, + ...rest + } = data; + return await prisma.eventType.create({ + data: { + ...rest, + ...(userId ? { owner: { connect: { id: userId } } } : null), + ...(profileId + ? { + profile: { + connect: { + id: profileId, + }, + }, + } + : null), + ...(teamId ? { team: { connect: { id: teamId } } } : null), + ...(parentId ? { parent: { connect: { id: parentId } } } : null), + ...(scheduleId ? { schedule: { connect: { id: scheduleId } } } : null), + ...(metadata ? { metadata: metadata } : null), + ...(bookingLimits + ? { + bookingLimits, + } + : null), + ...(recurringEvent + ? { + recurringEvent, + } + : null), + ...(bookingFields + ? { + bookingFields, + } + : null), + ...(durationLimits + ? { + durationLimits, + } + : null), + }, + }); + } + + static async findAllByUpId( + { upId }: { upId: string }, + { + orderBy, + where = {}, + }: { orderBy?: Prisma.EventTypeOrderByWithRelationInput[]; where?: Prisma.EventTypeWhereInput } = {} + ) { + if (!upId) return []; + const lookupTarget = ProfileRepository.getLookupTarget(upId); + const eventTypeWhere = { + ...(lookupTarget.type === LookupTarget.User + ? { + userId: lookupTarget.id, + } + : { + profileId: lookupTarget.id, + }), + ...where, + }; + log.debug( + "findAllByUpId", + safeStringify({ + upId, + orderBy, + argumentWhere: where, + where: eventTypeWhere, + }) + ); + return await prisma.eventType.findMany({ + where: eventTypeWhere, + include: { + // TODO: As required by getByViewHandler - Make it configurable + team: { + include: { + eventTypes: true, + }, + }, + hashedLink: true, + users: true, + children: { + include: { + users: true, + }, + }, + hosts: { + include: { + user: true, + }, + }, + }, + orderBy, + }); + } +} diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts new file mode 100644 index 0000000000..5123199875 --- /dev/null +++ b/packages/lib/server/repository/membership.ts @@ -0,0 +1,108 @@ +import { prisma } from "@calcom/prisma"; +import type { Prisma, MembershipRole } from "@calcom/prisma/client"; + +import logger from "../../logger"; +import { safeStringify } from "../../safeStringify"; +import { LookupTarget, ProfileRepository } from "./profile"; + +const log = logger.getSubLogger({ prefix: ["repository/membership"] }); +type IMembership = { + teamId: number; + userId: number; + profileId: number | null; + accepted: boolean; + role: MembershipRole; +}; + +export class MembershipRepository { + static async create(data: IMembership) { + return await prisma.membership.create({ + data, + }); + } + + static async createMany(data: IMembership[]) { + return await prisma.membership.createMany({ + data, + }); + } + + /** + * TODO: Using a specific function for specific tasks so that we don't have to focus on TS magic at the moment. May be try to make it a a generic findAllByProfileId with various options. + */ + static async findAllByUpIdIncludeTeamWithMembersAndEventTypes( + { upId }: { upId: string }, + { where }: { where?: Prisma.MembershipWhereInput } = {} + ) { + const lookupTarget = ProfileRepository.getLookupTarget(upId); + let prismaWhere; + if (lookupTarget.type === LookupTarget.Profile) { + /** + * TODO: When we add profileId to membership, we lookup by profileId + * If the profile is movedFromUser, we lookup all memberships without profileId as well. + */ + const profile = await ProfileRepository.findById(lookupTarget.id); + if (!profile) { + return []; + } + prismaWhere = { + userId: profile.user.id, + ...where, + }; + } else { + prismaWhere = { + userId: lookupTarget.id, + ...where, + }; + } + + log.debug( + "findAllByUpIdIncludeTeamWithMembersAndEventTypes", + safeStringify({ + prismaWhere, + }) + ); + + return await prisma.membership.findMany({ + where: prismaWhere, + include: { + team: { + include: { + members: true, + parent: true, + eventTypes: { + include: { + team: { + include: { + eventTypes: true, + }, + }, + hashedLink: true, + users: true, + hosts: { + include: { + user: true, + }, + }, + children: { + include: { + users: true, + }, + }, + }, + // As required by getByViewHandler - Make it configurable + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + }, + }, + }, + }, + }); + } +} diff --git a/packages/lib/server/repository/profile.ts b/packages/lib/server/repository/profile.ts new file mode 100644 index 0000000000..7baea0881f --- /dev/null +++ b/packages/lib/server/repository/profile.ts @@ -0,0 +1,500 @@ +import type { User as PrismaUser } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; + +import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { Team } from "@calcom/prisma/client"; +import type { UpId, UserAsPersonalProfile, UserProfile } from "@calcom/types/UserProfile"; + +import logger from "../../logger"; +import { getParsedTeam } from "./teamUtils"; +import { UserRepository } from "./user"; + +const log = logger.getSubLogger({ prefix: ["repository/profile"] }); +const organizationSelect = { + id: true, + slug: true, + name: true, + metadata: true, + calVideoLogo: true, +}; + +export enum LookupTarget { + User, + Profile, +} + +export class ProfileRepository { + static generateProfileUid() { + return uuidv4(); + } + + private static getInheritedDataFromUser({ user }: { user: PrismaUser }) { + return { + name: user.name, + avatarUrl: user.avatarUrl, + startTime: user.startTime, + endTime: user.endTime, + bufferTime: user.bufferTime, + avatar: user.avatar, + }; + } + + static getLookupTarget(upId: UpId) { + if (upId.startsWith("usr-")) { + return { + type: LookupTarget.User, + id: parseInt(upId.replace("usr-", "")), + } as const; + } + return { + type: LookupTarget.Profile, + id: parseInt(upId), + } as const; + } + + private static async _create({ + userId, + organizationId, + username, + email, + movedFromUserId, + }: { + userId: number; + organizationId: number; + username: string | null; + email: string; + movedFromUserId?: number; + }) { + log.debug("_create", safeStringify({ userId, organizationId, username, email })); + return prisma.profile.create({ + data: { + uid: ProfileRepository.generateProfileUid(), + user: { + connect: { + id: userId, + }, + }, + organization: { + connect: { + id: organizationId, + }, + }, + ...(movedFromUserId + ? { + movedFromUser: { + connect: { + id: movedFromUserId, + }, + }, + } + : null), + + username: username || email.split("@")[0], + }, + }); + } + + /** + * Accepts `email` as a source to derive username from when username is null + * @returns + */ + static create({ + userId, + organizationId, + username, + email, + }: { + userId: number; + organizationId: number; + username: string | null; + email: string; + }) { + return ProfileRepository._create({ userId, organizationId, username, email }); + } + + static async upsert({ + create, + update, + updateWhere, + }: { + create: { + userId: number; + organizationId: number; + username: string | null; + email: string; + }; + update: { + username: string | null; + email: string; + }; + updateWhere: { + userId: number; + organizationId: number; + }; + }) { + return prisma.profile.upsert({ + create: { + uid: ProfileRepository.generateProfileUid(), + user: { + connect: { + id: create.userId, + }, + }, + organization: { + connect: { + id: create.organizationId, + }, + }, + username: create.username || create.email.split("@")[0], + }, + update: { + username: update.username || update.email.split("@")[0], + }, + where: { + userId_organizationId: { + userId: updateWhere.userId, + organizationId: updateWhere.organizationId, + }, + }, + }); + } + + static async createForExistingUser({ + userId, + organizationId, + username, + email, + movedFromUserId, + }: { + userId: number; + organizationId: number; + username: string | null; + email: string; + movedFromUserId: number; + }) { + return await ProfileRepository._create({ + userId, + organizationId, + username, + email: email, + movedFromUserId, + }); + } + + static createMany({ + users, + organizationId, + }: { + users: { id: number; username: string; email: string }[]; + organizationId: number; + }) { + return prisma.profile.createMany({ + data: users.map((user) => ({ + uid: ProfileRepository.generateProfileUid(), + userId: user.id, + organizationId, + username: user.username || user.email.split("@")[0], + })), + }); + } + + static delete({ userId, organizationId }: { userId: number; organizationId: number }) { + // Even though there can be just one profile matching a userId and organizationId, we are using deleteMany as it won't error if the profile doesn't exist + return prisma.profile.deleteMany({ + where: { userId, organizationId }, + }); + } + + static deleteMany({ userIds }: { userIds: number[] }) { + // Even though there can be just one profile matching a userId and organizationId, we are using deleteMany as it won't error if the profile doesn't exist + return prisma.profile.deleteMany({ + where: { userId: { in: userIds } }, + }); + } + + static async findByUserIdAndOrgId({ + userId, + organizationId, + }: { + userId: number; + organizationId: number | null; + }) { + if (!organizationId) { + return null; + } + const profile = await prisma.profile.findFirst({ + where: { + userId, + organizationId, + }, + include: { + organization: { + select: organizationSelect, + }, + user: true, + }, + }); + + if (!profile) { + return null; + } + + const organization = getParsedTeam(profile.organization); + return normalizeProfile({ + ...profile, + organization: { + ...organization, + requestedSlug: organization.metadata?.requestedSlug ?? null, + metadata: organization.metadata, + }, + }); + } + + static async findByOrgIdAndUsername({ + organizationId, + username, + }: { + organizationId: number; + username: string; + }) { + const profile = await prisma.profile.findFirst({ + where: { + username, + organizationId, + }, + include: { + organization: { + select: organizationSelect, + }, + user: true, + }, + }); + return profile; + } + + static async findByUpId(upId: string) { + const lookupTarget = ProfileRepository.getLookupTarget(upId); + log.debug("findById", safeStringify({ upId, lookupTarget })); + if (lookupTarget.type === LookupTarget.User) { + const user = await UserRepository.findById({ id: lookupTarget.id }); + if (!user) { + return null; + } + return { + username: user.username, + upId: `usr-${user.id}`, + id: null, + organizationId: null, + organization: null, + ...ProfileRepository.getInheritedDataFromUser({ user }), + }; + } + + const profile = await ProfileRepository.findById(lookupTarget.id); + if (!profile) { + return null; + } + const user = profile.user; + return { + ...profile, + ...ProfileRepository.getInheritedDataFromUser({ user }), + }; + } + + static async findById(id: number | null) { + if (!id) { + return null; + } + + const profile = await prisma.profile.findUnique({ + where: { + id, + }, + include: { + user: true, + organization: { + include: { + members: true, + }, + }, + }, + }); + + if (!profile) { + return null; + } + + return normalizeProfile(profile); + } + + static async findManyByOrgSlugOrRequestedSlug({ + usernames, + orgSlug, + }: { + usernames: string[]; + orgSlug: string; + }) { + logger.debug("findManyByOrgSlugOrRequestedSlug", safeStringify({ usernames, orgSlug })); + const profiles = await prisma.profile.findMany({ + where: { + username: { + in: usernames, + }, + organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + }, + include: { + user: true, + organization: { + select: organizationSelect, + }, + }, + }); + + return profiles.map(normalizeProfile); + } + + static async findAllProfilesForUserIncludingMovedUser(user: { + id: number; + username: string | null; + }): Promise { + const profiles = await ProfileRepository.findManyForUser(user); + // User isn't member of any organization. Also, he has no user profile. We build the profile from user table + if (!profiles.length) { + return [ + ProfileRepository.buildPersonalProfileFromUser({ + user, + }), + ]; + } + + return profiles; + } + + static async findManyForUser(user: { id: number }) { + const profiles = ( + await prisma.profile.findMany({ + where: { + userId: user.id, + }, + include: { + organization: { + select: organizationSelect, + }, + }, + }) + ) + .map((profile) => { + return { + ...profile, + organization: getParsedTeam(profile.organization), + }; + }) + .map((profile) => { + return normalizeProfile({ + username: profile.username, + id: profile.id, + userId: profile.userId, + uid: profile.uid, + name: profile.organization.name, + organizationId: profile.organizationId, + organization: { + ...profile.organization, + requestedSlug: profile.organization.metadata?.requestedSlug ?? null, + metadata: profile.organization.metadata, + }, + }); + }); + return profiles; + } + + static async findManyForOrg({ organizationId }: { organizationId: number }) { + return await prisma.profile.findMany({ + where: { + organizationId, + }, + include: { + user: true, + organization: { + select: organizationSelect, + }, + }, + }); + } + + static async findByUserIdAndProfileId({ userId, profileId }: { userId: number; profileId: number }) { + const profile = await prisma.profile.findUnique({ + where: { + userId, + id: profileId, + }, + include: { + organization: { + select: organizationSelect, + }, + user: true, + }, + }); + if (!profile) { + return profile; + } + return normalizeProfile(profile); + } + + /** + * Personal profile should come from Profile table only + */ + static buildPersonalProfileFromUser({ + user, + }: { + user: { username: string | null; id: number }; + }): UserAsPersonalProfile { + return { + id: null, + upId: `usr-${user.id}`, + username: user.username, + organizationId: null, + organization: null, + }; + } + + static _getPrismaWhereForProfilesOfOrg({ orgSlug }: { orgSlug: string | null }) { + return { + profiles: { + ...(orgSlug + ? { + some: { + organization: { + slug: orgSlug, + }, + }, + } + : // If it's not orgSlug we want to ensure that no profile is there. Having a profile means that the user is a member of some organization. + { + none: {}, + }), + }, + }; + } +} + +export const normalizeProfile = < + T extends { + id: number; + organization: Pick; + createdAt?: Date; + updatedAt?: Date; + } +>( + profile: T +) => { + return { + ...profile, + upId: profile.id.toString(), + organization: getParsedTeam(profile.organization), + // Make these ↓ props ISO strings so that they can be returned from getServerSideProps as is without any issues + ...(profile.createdAt ? { createdAt: profile.createdAt.toISOString() } : null), + ...(profile.updatedAt ? { updatedAt: profile.updatedAt.toISOString() } : null), + }; +}; diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 26d63de555..0d71bce1ed 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -6,6 +6,8 @@ import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import { getParsedTeam } from "./teamUtils"; + type TeamGetPayloadWithParsedMetadata = | (Omit, "metadata"> & { metadata: z.infer; @@ -146,3 +148,16 @@ export async function getOrg({ teamSelect, }); } +export class TeamRepository { + static async findById({ id }: { id: number }) { + const team = await prisma.team.findUnique({ + where: { + id, + }, + }); + if (!team) { + return null; + } + return getParsedTeam(team); + } +} diff --git a/packages/lib/server/repository/teamUtils.ts b/packages/lib/server/repository/teamUtils.ts new file mode 100644 index 0000000000..8fb505f8be --- /dev/null +++ b/packages/lib/server/repository/teamUtils.ts @@ -0,0 +1,16 @@ +import type { Team } from "@calcom/prisma/client"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +export const getParsedTeam = (team: T) => { + const metadata = teamMetadataSchema.parse(team.metadata); + const requestedSlug = metadata?.requestedSlug ?? null; + const { metadata: _1, ...rest } = team; + return { + ...rest, + requestedSlug, + metadata: { + ...metadata, + requestedSlug, + }, + }; +}; diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts new file mode 100644 index 0000000000..706778d0fb --- /dev/null +++ b/packages/lib/server/repository/user.ts @@ -0,0 +1,336 @@ +import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; +import prisma from "@calcom/prisma"; +import type { UpId, UserProfile } from "@calcom/types/UserProfile"; + +import { isOrganization } from "../../entityPermissionUtils"; +import logger from "../../logger"; +import { safeStringify } from "../../safeStringify"; +import { ProfileRepository } from "./profile"; +import { getParsedTeam } from "./teamUtils"; +import type { User as UserType, Prisma } from ".prisma/client"; + +const log = logger.getSubLogger({ prefix: ["[repository/user]"] }); + +export const ORGANIZATION_ID_UNKNOWN = "ORGANIZATION_ID_UNKNOWN"; +export class UserRepository { + static async findTeamsByUserId({ userId }: { userId: UserType["id"] }) { + const teamMemberships = await prisma.membership.findMany({ + where: { + userId: userId, + }, + include: { + team: true, + }, + }); + + const acceptedTeamMemberships = teamMemberships.filter((membership) => membership.accepted); + const pendingTeamMemberships = teamMemberships.filter((membership) => !membership.accepted); + + return { + teams: acceptedTeamMemberships.map((membership) => membership.team), + memberships: teamMemberships, + acceptedTeamMemberships, + pendingTeamMemberships, + }; + } + + static async findOrganizations({ userId }: { userId: UserType["id"] }) { + const { acceptedTeamMemberships } = await UserRepository.findTeamsByUserId({ + userId, + }); + + const acceptedOrgMemberships = acceptedTeamMemberships.filter((membership) => + isOrganization({ team: membership.team }) + ); + + const organizations = acceptedOrgMemberships.map((membership) => membership.team); + + return { + organizations, + }; + } + + /** + * It is aware of the fact that a user can be part of multiple organizations. + */ + static async findUsersByUsername({ + orgSlug, + usernameList, + }: { + orgSlug: string | null; + usernameList: string[]; + }) { + const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({ + orgSlug, + usernameList, + }); + + log.debug("findUsersByUsername", safeStringify({ where, profiles })); + + return ( + await prisma.user.findMany({ + where, + }) + ).map((user) => { + // User isn't part of any organization + if (!profiles) { + return { + ...user, + profile: ProfileRepository.buildPersonalProfileFromUser({ user }), + }; + } + const profile = profiles.find((profile) => profile.user.id === user.id) ?? null; + if (!profile) { + log.error("Profile not found for user", safeStringify({ user, profiles })); + // Profile must be there because profile itself was used to retrieve the user + throw new Error("Profile couldn't be found"); + } + const { user: _1, ...profileWithoutUser } = profile; + return { + ...user, + profile: profileWithoutUser, + }; + }); + } + + static async _getWhereClauseForFindingUsersByUsername({ + orgSlug, + usernameList, + }: { + orgSlug: string | null; + usernameList: string[]; + }) { + // Lookup in profiles because that's where the organization usernames exist + const profiles = orgSlug + ? ( + await ProfileRepository.findManyByOrgSlugOrRequestedSlug({ + orgSlug: orgSlug, + usernames: usernameList, + }) + ).map((profile) => ({ + ...profile, + organization: getParsedTeam(profile.organization), + })) + : null; + + const where = profiles + ? { + // Get UserIds from profiles + id: { + in: profiles.map((profile) => profile.user.id), + }, + } + : { + username: { + in: usernameList, + }, + ...(orgSlug + ? { + organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + } + : { + organization: null, + }), + }; + + return { where, profiles }; + } + + static async findByEmailAndIncludeProfiles({ email }: { email: string }) { + const user = await prisma.user.findUnique({ + where: { + email: email.toLowerCase(), + }, + select: { + locked: true, + role: true, + id: true, + username: true, + name: true, + email: true, + metadata: true, + identityProvider: true, + password: true, + twoFactorEnabled: true, + twoFactorSecret: true, + backupCodes: true, + locale: true, + teams: { + include: { + team: true, + }, + }, + }, + }); + + if (!user) { + return null; + } + + const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user); + return { + ...user, + allProfiles, + }; + } + + static async findById({ id }: { id: number }) { + const user = await prisma.user.findUnique({ + where: { + id, + }, + }); + + if (!user) { + return null; + } + return user; + } + + static async findManyByOrganization({ organizationId }: { organizationId: number }) { + const profiles = await ProfileRepository.findManyForOrg({ organizationId }); + return profiles.map((profile) => profile.user); + } + + static isAMemberOfOrganization({ + user, + organizationId, + }: { + user: { profiles: { organizationId: number }[] }; + organizationId: number; + }) { + return user.profiles.some((profile) => profile.organizationId === organizationId); + } + + static async enrichUserWithTheProfile({ + user, + upId, + }: { + user: T; + upId: UpId; + }) { + log.debug("enrichUserWithTheProfile", safeStringify({ user, upId })); + const profile = await ProfileRepository.findByUpId(upId); + if (!profile) { + return { + ...user, + profile: ProfileRepository.buildPersonalProfileFromUser({ user }), + }; + } + return { + ...user, + profile, + }; + } + + /** + * Use this method if you don't directly has the profileId. + * It can happen in two cases: + * 1. While dealing with a User that hasn't been added to any organization yet and thus have no Profile entries. + * 2. While dealing with a User that has been moved to a Profile i.e. he was invited to an organization when he was an existing user. + */ + static async enrichUserWithItsProfile({ + user, + }: { + user: T; + }): Promise { + const profiles = await ProfileRepository.findManyForUser({ id: user.id }); + if (profiles.length) { + const profile = profiles[0]; + return { + ...user, + username: profile.username, + profile, + }; + } + + // If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always + return { + ...user, + profile: ProfileRepository.buildPersonalProfileFromUser({ user }), + }; + } + + static async enrichEntityWithProfile< + T extends + | { + profile: { + id: number; + username: string | null; + organizationId: number | null; + organization?: { + id: number; + name: string; + calVideoLogo: string | null; + slug: string | null; + metadata: Prisma.JsonValue; + }; + }; + } + | { + user: { + username: string | null; + id: number; + }; + } + >(entity: T) { + if ("profile" in entity) { + const { profile, ...entityWithoutProfile } = entity; + const { organization, ...profileWithoutOrganization } = profile || {}; + const parsedOrg = organization ? getParsedTeam(organization) : null; + + const ret = { + ...entityWithoutProfile, + profile: { + ...profileWithoutOrganization, + ...(parsedOrg + ? { + organization: parsedOrg, + } + : { + organization: null, + }), + }, + }; + return ret; + } else { + const profiles = await ProfileRepository.findManyForUser(entity.user); + if (!profiles.length) { + return { + ...entity, + profile: ProfileRepository.buildPersonalProfileFromUser({ user: entity.user }), + }; + } else { + return { + ...entity, + profile: profiles[0], + }; + } + } + } + + static async updateWhereId({ + whereId, + data, + }: { + whereId: number; + data: { + movedToProfileId?: number | null; + }; + }) { + return prisma.user.update({ + where: { + id: whereId, + }, + data: { + movedToProfile: data.movedToProfileId + ? { + connect: { + id: data.movedToProfileId, + }, + } + : undefined, + }, + }); + } +} diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 04c392a2ad..662adcf4c3 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -113,6 +113,7 @@ export const buildEventType = (eventType?: Partial): EventType => { successRedirectUrl: null, bookingFields: [], parentId: null, + profileId: null, ...eventType, }; }; @@ -236,6 +237,7 @@ export const buildUser = >(user?: T): UserPayload organizationId: null, allowSEOIndexing: null, receiveMonthlyDigestEmail: null, + movedToProfileId: null, ...user, }; }; diff --git a/packages/prisma/migrations/20240130134919_add_profile_table_and_add_event_type_relation/migration.sql b/packages/prisma/migrations/20240130134919_add_profile_table_and_add_event_type_relation/migration.sql new file mode 100644 index 0000000000..fdc034a571 --- /dev/null +++ b/packages/prisma/migrations/20240130134919_add_profile_table_and_add_event_type_relation/migration.sql @@ -0,0 +1,65 @@ +/* + Warnings: + + - A unique constraint covering the columns `[movedToProfileId]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "profileId" INTEGER; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "movedToProfileId" INTEGER; + +-- CreateTable +CREATE TABLE "Profile" ( + "id" SERIAL NOT NULL, + "uid" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "organizationId" INTEGER NOT NULL, + "username" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Profile_uid_idx" ON "Profile"("uid"); + +-- CreateIndex +CREATE INDEX "Profile_userId_idx" ON "Profile"("userId"); + +-- CreateIndex +CREATE INDEX "Profile_organizationId_idx" ON "Profile"("organizationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Profile_userId_organizationId_key" ON "Profile"("userId", "organizationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_movedToProfileId_key" ON "users"("movedToProfileId"); + +-- AddForeignKey +ALTER TABLE "EventType" ADD CONSTRAINT "EventType_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_movedToProfileId_fkey" FOREIGN KEY ("movedToProfileId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Copy Data to Profile table +WITH new_profile AS ( + INSERT INTO "Profile" ("uid", "organizationId", "userId", "username", "updatedAt") + SELECT "id" as "uid", "organizationId", "id" AS "userId", "username", NOW() + FROM "users" + WHERE "organizationId" IS NOT NULL + RETURNING "uid", "userId", "id" +) +UPDATE "users" +SET "movedToProfileId" = "new_profile"."id" +FROM "new_profile" +WHERE "users"."id" = "new_profile"."userId"; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 89a87ace2d..df0ed82720 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -47,23 +47,27 @@ model Host { } model EventType { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @zod.min(1) - title String + title String /// @zod.custom(imports.eventTypeSlug) - slug String - description String? - position Int @default(0) + slug String + description String? + position Int @default(0) /// @zod.custom(imports.eventTypeLocations) - locations Json? + locations Json? /// @zod.min(1) - length Int - offsetStart Int @default(0) - hidden Boolean @default(false) - hosts Host[] - users User[] @relation("user_eventtype") - owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) - userId Int? + length Int + offsetStart Int @default(0) + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + + profileId Int? + profile Profile? @relation(fields: [profileId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId Int? hashedLink HashedLink? @@ -185,6 +189,7 @@ enum UserPermissionRole { ADMIN } +// It holds Personal Profiles of a User plus it has email, password and other core things model User { id Int @id @default(autoincrement()) username String? @@ -260,17 +265,17 @@ model User { accessCodes AccessCode[] bookingRedirects OutOfOfficeEntry[] bookingRedirectsTo OutOfOfficeEntry[] @relation(name: "toUser") - // Linking account code for orgs v2 - //linkedByUserId Int? - //linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade) - //linkedUsers User[] @relation("linked_account")*/ // Used to lock the user account - locked Boolean @default(false) + locked Boolean @default(false) + profiles Profile[] + movedToProfileId Int? + movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) @@unique([email]) @@unique([email, username]) @@unique([username, organizationId]) + @@unique([movedToProfileId]) @@index([username]) @@index([emailVerified]) @@index([identityProvider]) @@ -278,6 +283,27 @@ model User { @@map(name: "users") } +// It holds Organization Profiles +model Profile { + id Int @id @default(autoincrement()) + // uid allows us to set an identifier chosen by us which is helpful in migration when we create the Profile from User directly. + uid String + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId Int + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + username String + eventTypes EventType[] + movedFromUser User? @relation("moved_to_profile") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, organizationId]) + @@index([uid]) + @@index([userId]) + @@index([organizationId]) +} + model Team { id Int @id @default(autoincrement()) /// @zod.min(1) @@ -317,6 +343,7 @@ model Team { credentials Credential[] accessCodes AccessCode[] instantMeetingTokens InstantMeetingToken[] + orgProfiles Profile[] pendingPayment Boolean @default(false) @@unique([slug, parentId]) diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index e311117ed8..088e98aa75 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -1,4 +1,5 @@ -import type { Prisma } from "@prisma/client"; +import type { Membership, Team, User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { uuid } from "short-uuid"; import type z from "zod"; @@ -6,7 +7,9 @@ import dailyMeta from "@calcom/app-store/dailyvideo/_metadata"; import googleMeetMeta from "@calcom/app-store/googlevideo/_metadata"; import zoomMeta from "@calcom/app-store/zoomvideo/_metadata"; import dayjs from "@calcom/dayjs"; +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; +import type { Ensure } from "@calcom/types/utils"; import prisma from "."; import mainAppStore from "./seed-app-store"; @@ -75,6 +78,260 @@ async function createTeamAndAddUsers( return team; } +async function createOrganizationAndAddMembersAndTeams({ + org: { orgData, members: orgMembers }, + teams, + usersOutsideOrg, +}: { + org: { + orgData: Ensure, "name" | "slug">; + members: { + memberData: Ensure, "username" | "name" | "email" | "password">; + orgMembership: Partial; + orgProfile: { + username: string; + }; + inTeams: { slug: string; role: MembershipRole }[]; + }[]; + }; + teams: { + teamData: Omit, "name" | "slug">, "members">; + nonOrgMembers: Ensure, "username" | "name" | "email" | "password">[]; + }[]; + usersOutsideOrg: { + name: string; + username: string; + email: string; + }[]; +}) { + console.log(`\n🏢 Creating organization "${orgData.name}"`); + const orgMembersInDb: (User & { + inTeams: { slug: string; role: MembershipRole }[]; + orgMembership: Partial; + orgProfile: { + username: string; + }; + })[] = []; + + // Create all users first + try { + for (const member of orgMembers) { + const orgMemberInDb = { + ...(await prisma.user.create({ + data: { + ...member.memberData, + password: await hashPassword(member.memberData.password), + }, + })), + inTeams: member.inTeams, + orgMembership: member.orgMembership, + orgProfile: member.orgProfile, + }; + + orgMembersInDb.push(orgMemberInDb); + } + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + console.log(`One of the organization members already exists, skipping the entire seeding`); + return; + } + } + } + + await Promise.all([ + usersOutsideOrg.map(async (user) => { + return await prisma.user.create({ + data: { + username: user.username, + name: user.name, + email: user.email, + password: await hashPassword(user.username), + }, + }); + }), + ]); + + // Create organization with those users as members + const orgInDb = await prisma.team.create({ + data: { + ...orgData, + metadata: { + ...(orgData.metadata && typeof orgData.metadata === "object" ? orgData.metadata : {}), + isOrganization: true, + }, + orgProfiles: { + create: orgMembersInDb.map((member) => ({ + uid: uuid(), + username: member.orgProfile.username, + user: { + connect: { + id: member.id, + }, + }, + })), + }, + members: { + create: orgMembersInDb.map((member) => ({ + user: { + connect: { + id: member.id, + }, + }, + role: member.orgMembership.role || "MEMBER", + accepted: member.orgMembership.accepted, + })), + }, + }, + select: { + id: true, + members: true, + orgProfiles: true, + }, + }); + + const orgMembersInDBWithProfileId = await Promise.all( + orgMembersInDb.map(async (member) => ({ + ...member, + profile: { + ...member.orgProfile, + id: orgInDb.orgProfiles.find((p) => p.userId === member.id)?.id, + }, + })) + ); + + // For each member create one event + for (const member of orgMembersInDBWithProfileId) { + await prisma.eventType.create({ + data: { + title: `${member.name} Event`, + slug: `${member.username}-event`, + length: 15, + owner: { + connect: { + id: member.id, + }, + }, + profile: { + connect: { + id: member.profile.id, + }, + }, + users: { + connect: { + id: member.id, + }, + }, + }, + }); + + // Create schedule for every member + await prisma.schedule.create({ + data: { + name: "Working Hours", + userId: member.id, + availability: { + create: { + days: [1, 2, 3, 4, 5], + startTime: "1970-01-01T09:00:00.000Z", + endTime: "1970-01-01T17:00:00.000Z", + }, + }, + }, + }); + } + + const organizationTeams: Team[] = []; + + // Create all the teams in the organization + for (let teamIndex = 0; teamIndex < teams.length; teamIndex++) { + const nonOrgMembers: User[] = []; + const team = teams[teamIndex]; + for (const nonOrgMember of team.nonOrgMembers) { + nonOrgMembers.push( + await prisma.user.create({ + data: { + ...nonOrgMember, + password: await hashPassword(nonOrgMember.password), + }, + }) + ); + } + organizationTeams.push( + await prisma.team.create({ + data: { + ...team.teamData, + parent: { + connect: { + id: orgInDb.id, + }, + }, + metadata: team.teamData.metadata || {}, + members: { + create: nonOrgMembers.map((member) => ({ + user: { + connect: { + id: member.id, + }, + }, + role: "MEMBER", + accepted: true, + })), + }, + }, + }) + ); + + const ownerForEvent = orgMembersInDBWithProfileId[0]; + // Create event for each team + await prisma.eventType.create({ + data: { + title: `${team.teamData.name} Event1`, + slug: `${team.teamData.slug}-event-1`, + length: 15, + team: { + connect: { + id: organizationTeams[teamIndex].id, + }, + }, + owner: { + connect: { + id: ownerForEvent.id, + }, + }, + profile: { + connect: { + id: ownerForEvent.profile.id, + }, + }, + users: { + connect: { + id: ownerForEvent.id, + }, + }, + }, + }); + } + + // Create memberships for all the organization members with the respective teams + for (const member of orgMembersInDBWithProfileId) { + for (const { slug: teamSlug, role: role } of member.inTeams) { + const team = organizationTeams.find((t) => t.slug === teamSlug); + if (!team) { + throw Error(`Team with slug ${teamSlug} not found`); + } + await prisma.membership.create({ + data: { + teamId: team.id, + userId: member.id, + role: role, + accepted: true, + }, + }); + } + } +} + async function main() { await createUserAndEventType({ user: { @@ -116,6 +373,7 @@ async function main() { }, ], }); + await createUserAndEventType({ user: { email: "pro@example.com", @@ -530,6 +788,124 @@ async function main() { }, ] ); + + await createOrganizationAndAddMembersAndTeams({ + org: { + orgData: { + name: "Acme Inc", + slug: "acme", + metadata: { + isOrganizationVerified: true, + orgAutoAcceptEmail: "acme.com", + }, + }, + members: [ + { + memberData: { + email: "owner1-acme@example.com", + password: "owner1-acme", + username: "owner1-acme", + name: "Owner 1", + }, + orgMembership: { + role: "OWNER", + accepted: true, + }, + orgProfile: { + username: "owner1", + }, + inTeams: [ + { + slug: "team1", + role: "ADMIN", + }, + ], + }, + ], + }, + teams: [ + { + teamData: { + name: "Team 1", + slug: "team1", + }, + nonOrgMembers: [ + { + email: "non-acme-member-1@example.com", + password: "non-acme-member-1", + username: "non-acme-member-1", + name: "NonAcme Member1", + }, + ], + }, + ], + usersOutsideOrg: [ + { + name: "Jane Doe", + email: "jane@acme.com", + username: "jane-outside-org", + }, + ], + }); + + await createOrganizationAndAddMembersAndTeams({ + org: { + orgData: { + name: "Dunder Mifflin", + slug: "dunder-mifflin", + metadata: { + isOrganizationVerified: true, + orgAutoAcceptEmail: "dunder-mifflin.com", + }, + }, + members: [ + { + memberData: { + email: "owner1-dunder@example.com", + password: "owner1-dunder", + username: "owner1-dunder", + name: "Owner 1", + }, + orgMembership: { + role: "OWNER", + accepted: true, + }, + orgProfile: { + username: "owner1", + }, + inTeams: [ + { + slug: "team1", + role: "ADMIN", + }, + ], + }, + ], + }, + teams: [ + { + teamData: { + name: "Team 1", + slug: "team1", + }, + nonOrgMembers: [ + { + email: "non-dunder-member-1@example.com", + password: "non-dunder-member-1", + username: "non-dunder-member-1", + name: "NonDunder Member1", + }, + ], + }, + ], + usersOutsideOrg: [ + { + name: "John Doe", + email: "john@dunder-mifflin.com", + username: "john-outside-org", + }, + ], + }); } main() diff --git a/packages/prisma/selects/user.ts b/packages/prisma/selects/user.ts index dffa09a298..953cc7c09d 100644 --- a/packages/prisma/selects/user.ts +++ b/packages/prisma/selects/user.ts @@ -51,7 +51,6 @@ export const userSelect = Prisma.validator()({ brandColor: true, darkBrandColor: true, metadata: true, - organizationId: true, ...availabilityUserSelect, }, }); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 46d1cb3b1e..1a2e0c5caf 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -336,7 +336,7 @@ export type userMetadataType = z.infer; export const teamMetadataSchema = z .object({ - requestedSlug: z.string(), + requestedSlug: z.string().or(z.null()), paymentId: z.string(), subscriptionId: z.string().nullable(), subscriptionItemId: z.string().nullable(), diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 92bdc47f72..c1ea86eed4 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -2,7 +2,9 @@ import type { Session } from "next-auth"; import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -14,11 +16,15 @@ type Maybe = T | null | undefined; export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe) { const { prisma } = ctx; - if (!session?.user?.id) { + if (!session) { return null; } - const user = await prisma.user.findUnique({ + if (!session.user?.id) { + return null; + } + + const userFromDb = await prisma.user.findUnique({ where: { id: session.user.id, // Locked users can't login @@ -47,6 +53,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe (member.role === "ADMIN" || member.role === "OWNER") && member.userId === user.id + ).length; + + if (isOrgAdmin) { + logger.debug("User is an org admin", safeStringify({ userId: user.id })); + } else { + logger.debug("User is not an org admin", safeStringify({ userId: user.id })); } + // Want to reduce the amount of data being sent + if (isOrgAdmin && user.profile?.organization?.members) { + user.profile.organization.members = []; + } + + const organization = { + ...user.profile?.organization, + id: user.profile?.organization?.id ?? null, + isOrgAdmin, + metadata: orgMetadata, + requestedSlug: orgMetadata?.requestedSlug ?? null, + }; + return { ...user, - avatar: - `${WEBAPP_URL}/${user.username}/avatar.png?${user.organizationId}` && `orgId=${user.organizationId}`, - organization: { - ...user.organization, - isOrgAdmin, - metadata: orgMetadata, - }, + avatar: `${WEBAPP_URL}/${user.username}/avatar.png?${organization.id}` && `orgId=${organization.id}`, + // TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now. + organization, + organizationId: organization.id, id, email, username, @@ -135,8 +152,39 @@ const getUserSession = async (ctx: TRPCContextInner) => { */ const session = ctx.session || (await getSession(ctx)); const user = session ? await getUserFromSession(ctx, session) : null; + let foundProfile = null; + // Check authorization for profile + if (session?.profileId && user?.id) { + foundProfile = await ProfileRepository.findByUserIdAndProfileId({ + userId: user.id, + profileId: session.profileId, + }); + if (!foundProfile) { + logger.error( + "Profile not found or not authorized", + safeStringify({ profileId: session.profileId, userId: user?.id }) + ); + // TODO: Test that logout should happen automatically + throw new TRPCError({ code: "UNAUTHORIZED", message: "Profile not found or not authorized" }); + } + } - return { user, session }; + let sessionWithUpId = null; + if (session) { + let upId = session.upId; + if (!upId) { + upId = foundProfile?.upId ?? `usr-${user?.id}`; + } + + if (!upId) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No upId found for session" }); + } + sessionWithUpId = { + ...session, + upId, + }; + } + return { user, session: sessionWithUpId }; }; const sessionMiddleware = middleware(async ({ ctx, next }) => { diff --git a/packages/trpc/server/routers/loggedInViewer/appById.handler.ts b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts index f7d81703f4..83d0a8ac3c 100644 --- a/packages/trpc/server/routers/loggedInViewer/appById.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appById.handler.ts @@ -16,7 +16,7 @@ type AppByIdOptions = { export const appByIdHandler = async ({ ctx, input }: AppByIdOptions) => { const { user } = ctx; const appId = input.appId; - const credentials = await getUsersCredentials(user.id); + const credentials = await getUsersCredentials(user); const apps = getApps(credentials); const appFromDb = apps.find((app) => app.slug === appId); if (!appFromDb) { diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts index fddfb0b75e..1a4af97b41 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -53,7 +53,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) = sortByMostPopular, appId, } = input; - let credentials = await getUsersCredentials(user.id); + let credentials = await getUsersCredentials(user); let userTeams: TeamQuery[] = []; if (includeTeamInstalledApps || teamId) { diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 4c4b72f648..1123cd7372 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -1,21 +1,36 @@ +import type { Session } from "next-auth"; + import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type MeOptions = { ctx: { user: NonNullable; + session: Session; }; }; export const meHandler = async ({ ctx }: MeOptions) => { const crypto = await import("crypto"); - const { user } = ctx; + + const { user: sessionUser, session } = ctx; + + const allUserEnrichedProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser( + sessionUser + ); + + const user = await UserRepository.enrichUserWithTheProfile({ + user: sessionUser, + upId: session.upId, + }); + // 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, emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), startTime: user.startTime, @@ -45,7 +60,10 @@ export const meHandler = async ({ ctx }: MeOptions) => { allowDynamicBooking: user.allowDynamicBooking, allowSEOIndexing: user.allowSEOIndexing, receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail, - organizationId: user.organizationId, + organizationId: user.profile?.organizationId ?? null, organization: user.organization, + username: user.profile?.username ?? user.username ?? null, + profile: user.profile ?? null, + profiles: allUserEnrichedProfiles, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts index 10bde57dad..71c6abd66e 100644 --- a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts @@ -17,7 +17,7 @@ type SetDestinationCalendarOptions = { export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinationCalendarOptions) => { const { user } = ctx; const { integration, externalId, eventTypeId } = input; - const credentials = await getUsersCredentials(user.id); + const credentials = await getUsersCredentials(user); const calendarCredentials = getCalendarCredentials(credentials); const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); diff --git a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts index 4dd6a0ef2f..ba4ea7fe66 100644 --- a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts @@ -16,11 +16,13 @@ type TeamsAndUserProfileOptions = { export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOptions) => { const { prisma } = ctx; + const profile = ctx.user.profile; const user = await prisma.user.findUnique({ where: { id: ctx.user.id, }, select: { + avatarUrl: true, id: true, username: true, name: true, @@ -47,7 +49,6 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti }, }, }, - organizationId: true, }, }); if (!user) { @@ -69,7 +70,10 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti teamId: null, name: user.name, slug: user.username, - image: getUserAvatarUrl(user), + image: getUserAvatarUrl({ + ...user, + profile: profile, + }), readOnly: false, }, ...nonOrgTeams.map((membership) => ({ diff --git a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts index 4fb3827bec..d0bfafd5bf 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateUserDefaultConferencingApp.handler.ts @@ -22,7 +22,7 @@ export const updateUserDefaultConferencingAppHandler = async ({ input, }: UpdateUserDefaultConferencingAppOptions) => { const currentMetadata = userMetadata.parse(ctx.user.metadata); - const credentials = await getUsersCredentials(ctx.user.id); + const credentials = await getUsersCredentials(ctx.user); const foundApp = getApps(credentials, true).filter((app) => app.slug === input.appSlug)[0]; const appLocation = foundApp?.appData?.location; diff --git a/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts b/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts index 552ebb1f6f..4bf2516fc9 100644 --- a/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts @@ -98,11 +98,13 @@ export const workflowOrderHandler = async ({ ctx, input }: RoutingFormOrderOptio ); }; +type SupportedFilters = Omit["filters"]>, "upIds"> | undefined; + export function getPrismaWhereFromFilters( user: { id: number; }, - filters: NonNullable["filters"] + filters: SupportedFilters ) { const where = { OR: [] as Prisma.App_RoutingForms_FormWhereInput[], diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts index d0b43d918a..401c88eceb 100644 --- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -50,7 +50,6 @@ const listPaginatedHandler = async ({ input }: GetOptions) => { name: true, timeZone: true, role: true, - organizationId: true, }, }); diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 6d6d709ffe..c2f3cf8e2c 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -4,6 +4,7 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import type { DateRange } from "@calcom/lib/date-ranges"; import { buildDateRanges } from "@calcom/lib/date-ranges"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -20,16 +21,18 @@ type GetOptions = { async function getTeamMembers({ teamId, + organizationId, teamIds, cursor, limit, }: { teamId?: number; + organizationId: number | null; teamIds?: number[]; cursor: number | null | undefined; limit: number; }) { - return await prisma.membership.findMany({ + const memberships = await prisma.membership.findMany({ where: { teamId: { in: teamId ? [teamId] : teamIds, @@ -40,8 +43,8 @@ async function getTeamMembers({ role: true, user: { select: { + avatarUrl: true, id: true, - organizationId: true, username: true, name: true, email: true, @@ -57,6 +60,18 @@ async function getTeamMembers({ }, distinct: ["userId"], }); + + const membershipWithUserProfile = []; + for (const membership of memberships) { + membershipWithUserProfile.push({ + ...membership, + user: await UserRepository.enrichUserWithItsProfile({ + user: membership.user, + }), + }); + } + + return membershipWithUserProfile; } type Member = Awaited>[number]; @@ -65,7 +80,7 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { if (!member.user.defaultScheduleId) { return { id: member.user.id, - organizationId: member.user.organizationId, + organizationId: member.user.profile?.organizationId ?? null, name: member.user.name, username: member.user.username, email: member.user.email, @@ -93,7 +108,9 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { id: member.user.id, username: member.user.username, email: member.user.email, - organizationId: member.user.organizationId, + avatarUrl: member.user.avatarUrl, + profile: member.user.profile, + organizationId: member.user.profile?.organizationId, name: member.user.name, timeZone, role: member.role, @@ -124,6 +141,7 @@ async function getInfoForAllTeams({ ctx, input }: GetOptions) { const teamMembers = await getTeamMembers({ teamIds, + organizationId: ctx.user.organizationId, cursor, limit, }); @@ -180,6 +198,7 @@ export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) => teamId, cursor, limit, + organizationId: ctx.user.organizationId, }); } } diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 51d49acd99..a1a1351ee7 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -219,7 +219,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { } if (confirmed) { - const credentials = await getUsersCredentials(user.id); + const credentials = await getUsersCredentials(user); const userWithCredentials = { ...user, credentials, diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index cfb1017a0b..c780171712 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -93,7 +93,7 @@ export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) = seatsShowAttendees: booking.eventType?.seatsShowAttendees, }; - const credentials = await getUsersCredentials(ctx.user.id); + const credentials = await getUsersCredentials(ctx.user); const eventManager = new EventManager({ ...ctx.user, diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index eb1dbb1a13..43cbc8aa63 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -187,8 +187,8 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule builder.init({ title: bookingToReschedule.title, bookerUrl: eventType?.team - ? await getBookerBaseUrl({ organizationId: eventType.team.parentId }) - : await getBookerBaseUrl(user), + ? await getBookerBaseUrl(eventType.team.parentId) + : await getBookerBaseUrl(user.profile?.organizationId ?? null), type: event && event.slug ? event.slug : bookingToReschedule.title, startTime: bookingToReschedule.startTime.toISOString(), endTime: bookingToReschedule.endTime.toISOString(), @@ -213,7 +213,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule // Handling calendar and videos cancellation // This can set previous time as available, until virtual calendar is done - const credentials = await getUsersCredentials(user.id); + const credentials = await getUsersCredentials(user); const credentialsMap = new Map(); credentials.forEach((credential) => { credentialsMap.set(credential.type, credential); diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 61cc7ea3ff..ac7277f2cc 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -5,6 +5,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; import { DailyLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; import type { PrismaClient } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -43,7 +44,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { } if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { - const credentials = await getUsersCredentials(ctx.user.id); + const credentials = await getUsersCredentials(ctx.user); const foundApp = getApps(credentials, true).filter( (app) => app.slug === defaultConferencingData.appSlug )[0]; // There is only one possible install here so index [0] is the one we are looking for ; @@ -84,10 +85,15 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { data.schedulingType = schedulingType; } + const profile = ctx.user.profile; try { - const eventType = await ctx.prisma.eventType.create({ data }); + const eventType = await EventTypeRepository.create({ + ...data, + profileId: profile.id, + }); return { eventType }; } catch (e) { + console.warn(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." }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts index 250032937b..52e1a121c5 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts @@ -1,5 +1,6 @@ import { Prisma } from "@prisma/client"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -97,7 +98,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, }; - const newEventType = await prisma.eventType.create({ data }); + const newEventType = await EventTypeRepository.create(data); // Create custom inputs if (customInputs) { diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts index 253c6e9e3c..0987340771 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts @@ -14,6 +14,7 @@ type GetOptions = { export const getHandler = ({ ctx, input }: GetOptions) => { return getEventTypeById({ + currentOrganizationId: ctx.user.profile?.organizationId ?? null, eventTypeId: input.id, userId: ctx.user.id, prisma: ctx.prisma, diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index 9ce7b0ae0f..75b84bd399 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -1,15 +1,20 @@ -import { Prisma } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { orderBy } from "lodash"; import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { isOrganization } from "@calcom/lib/entityPermissionUtils"; import { getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; +import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; -import { baseEventTypeSelect } from "@calcom/prisma"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -19,6 +24,7 @@ import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; import type { TEventTypeInputSchema } from "./getByViewer.schema"; +const log = logger.getSubLogger({ prefix: ["viewer.eventTypes.getByViewer"] }); type GetByViewerOptions = { ctx: { user: NonNullable; @@ -27,56 +33,6 @@ type GetByViewerOptions = { input: TEventTypeInputSchema; }; -const userSelect = Prisma.validator()({ - id: true, - username: true, - name: true, - organizationId: true, -}); - -const userEventTypeSelect = 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, - 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: userSelect, - }, - parentId: true, - hosts: { - select: { - user: { - select: userSelect, - }, - }, - }, - seatsPerTimeSlot: true, - ...baseEventTypeSelect, -}); - -const teamEventTypeSelect = Prisma.validator()({ - ...userEventTypeSelect, - children: { - include: { - users: { - select: userSelect, - }, - }, - }, -}); - export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => { const mshipToNumber = (mship: MembershipRole) => Object.keys(MembershipRole).findIndex((mmship) => mmship === mship); @@ -84,85 +40,54 @@ export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole }; export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => { - const { prisma } = ctx; - await checkRateLimitAndThrowError({ identifier: `eventTypes:getByViewer:${ctx.user.id}`, rateLimitingType: "common", }); + const lightProfile = ctx.user.profile; - 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, - organizationId: true, - teams: { + const profile = await ProfileRepository.findByUpId(lightProfile.upId); + const isFilterSet = input?.filters && hasFilter(input.filters); + const isUpIdInFilter = input?.filters?.upIds?.includes(lightProfile.upId); + const shouldListUserEvents = !isFilterSet || isUpIdInFilter; + const [profileMemberships, profileEventTypes] = await Promise.all([ + MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes( + { + upId: lightProfile.upId, + }, + { where: { accepted: true, }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - parentId: true, - metadata: true, - parent: true, - members: { - select: { - userId: true, - }, - }, - eventTypes: { - select: teamEventTypeSelect, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }, + } + ), + shouldListUserEvents + ? EventTypeRepository.findAllByUpId( + { + upId: lightProfile.upId, + }, + { + where: { + teamId: null, }, - }, - }, - }, - eventTypes: { - where: { - teamId: null, - userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters), - }, - select: { - ...userEventTypeSelect, - }, - orderBy: [ - { - position: "desc", - }, - { - id: "asc", - }, - ], - }, - }, - }); + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], + } + ) + : [], + ]); - if (!user) { + if (!profile) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const memberships = user.teams.map((membership) => ({ + const memberships = profileMemberships.map((membership) => ({ ...membership, team: { ...membership.team, @@ -170,18 +95,43 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }, })); - type UserEventTypes = (typeof user.eventTypes)[number]; - type TeamEventTypeChildren = (typeof user.teams)[number]["team"]["eventTypes"][number]; + log.debug( + safeStringify({ + profileMemberships, + profileEventTypes, + }) + ); - const mapEventType = (eventType: UserEventTypes & Partial) => ({ + type UserEventTypes = (typeof profileEventTypes)[number]; + type TeamEventTypeChildren = NonNullable<(typeof profileEventTypes)[number]["team"]>["eventTypes"][number]; + const mapEventType = async (eventType: UserEventTypes & Partial) => ({ ...eventType, safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined, - users: !!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users, + users: await Promise.all( + (!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map( + async (u) => + await UserRepository.enrichUserWithItsProfile({ + user: u, + }) + ) + ), metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, - children: eventType.children, + children: await Promise.all( + (eventType.children || []).map(async (c) => ({ + ...c, + users: await Promise.all( + c.users.map( + async (u) => + await UserRepository.enrichUserWithItsProfile({ + user: u, + }) + ) + ), + })) + ), }); - const userEventTypes = user.eventTypes.map(mapEventType); + const userEventTypes = await Promise.all(profileEventTypes.map(mapEventType)); type EventTypeGroup = { teamId?: number | null; @@ -189,8 +139,8 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => bookerUrl: string; membershipRole?: MembershipRole | null; profile: { - slug: (typeof user)["username"]; - name: (typeof user)["name"]; + slug: (typeof profile)["username"] | null; + name: (typeof profile)["name"]; image: string; }; metadata: { @@ -206,16 +156,30 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => (evType) => evType.schedulingType !== SchedulingType.MANAGED ); - if (!input?.filters || !hasFilter(input?.filters) || input?.filters?.userIds?.includes(user.id)) { - const bookerUrl = await getBookerBaseUrl(user); + log.debug(safeStringify({ profileMemberships, profileEventTypes, profile })); + + log.debug( + "Filter Settings", + safeStringify({ + isFilterSet, + isProfileIdInFilter: isUpIdInFilter, + }) + ); + + if (!isFilterSet || isUpIdInFilter) { + const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null); eventTypeGroups.push({ teamId: null, bookerUrl, membershipRole: null, profile: { - slug: user.username, - name: user.name, - image: getUserAvatarUrl({ username: user.username, organizationId: user.organizationId }), + slug: profile.username, + name: profile.name, + image: getUserAvatarUrl({ + username: profile.username, + avatarUrl: profile.avatarUrl, + profile: profile, + }), }, eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { @@ -225,12 +189,12 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }); } - const teamMemberships = user.teams.map((membership) => ({ + const teamMemberships = profileMemberships.map((membership) => ({ teamId: membership.team.id, membershipRole: membership.role, })); - const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType) => { + const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited>) => { if (!input?.filters || !hasFilter(input?.filters)) { return true; } @@ -238,76 +202,81 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }; eventTypeGroups = ([] as EventTypeGroup[]).concat( eventTypeGroups, - memberships - .filter((mmship) => { - const metadata = mmship.team.metadata; - if (metadata?.isOrganization) { - return false; - } else { - if (!input?.filters || !hasFilter(input?.filters)) { - return true; + await Promise.all( + memberships + .filter((mmship) => { + if (isOrganization({ team: mmship.team })) { + return false; + } else { + if (!input?.filters || !hasFilter(input?.filters)) { + return true; + } + return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false; } - return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false; - } - }) - .map((membership) => { - const orgMembership = teamMemberships.find( - (teamM) => teamM.teamId === membership.team.parentId - )?.membershipRole; + }) + .map(async (membership) => { + const orgMembership = teamMemberships.find( + (teamM) => teamM.teamId === membership.team.parentId + )?.membershipRole; - const team = { - ...membership.team, - metadata: teamMetadataSchema.parse(membership.team.metadata), - }; + const team = { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }; - let slug; + let slug; - if (input?.forRoutingForms) { - // For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team - // Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug - slug = `team/${team.slug}`; - } else { - // In an Org, a team can be accessed without /team prefix as well as with /team prefix - slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null; - } - return { - teamId: team.id, - parentId: team.parentId, - bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null), - membershipRole: - orgMembership && compareMembership(orgMembership, membership.role) - ? orgMembership - : membership.role, - profile: { - image: getTeamAvatarUrl({ - slug: team.slug, - requestedSlug: team.metadata?.requestedSlug ?? null, - organizationId: team.parentId, - }), - name: team.name, - slug, - }, - metadata: { - membershipCount: team.members.length, - readOnly: - membership.role === - (team.parentId - ? orgMembership && compareMembership(orgMembership, membership.role) - ? orgMembership - : MembershipRole.MEMBER - : MembershipRole.MEMBER), - }, - eventTypes: team.eventTypes - .map(mapEventType) - .filter(filterTeamsEventTypesBasedOnInput) - .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) - .filter((evType) => - membership.role === MembershipRole.MEMBER - ? evType.schedulingType !== SchedulingType.MANAGED - : true - ), - }; - }) + if (input?.forRoutingForms) { + // For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team + // Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug + slug = `team/${team.slug}`; + } else { + // In an Org, a team can be accessed without /team prefix as well as with /team prefix + slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null; + } + + const eventTypes = await Promise.all(team.eventTypes.map(mapEventType)); + return { + teamId: team.id, + parentId: team.parentId, + bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null), + membershipRole: + orgMembership && compareMembership(orgMembership, membership.role) + ? orgMembership + : membership.role, + profile: { + image: getTeamAvatarUrl({ + slug: team.slug, + requestedSlug: team.metadata?.requestedSlug ?? null, + organizationId: team.parentId, + }), + name: team.name, + slug, + }, + metadata: { + membershipCount: team.members.length, + readOnly: + membership.role === + (team.parentId + ? orgMembership && compareMembership(orgMembership, membership.role) + ? orgMembership + : MembershipRole.MEMBER + : MembershipRole.MEMBER), + }, + eventTypes: eventTypes + .filter(filterTeamsEventTypesBasedOnInput) + .filter((evType) => { + const res = evType.userId === null || evType.userId === ctx.user.id; + return res; + }) + .filter((evType) => + membership.role === MembershipRole.MEMBER + ? evType.schedulingType !== SchedulingType.MANAGED + : true + ), + }; + }) + ) ); return { @@ -321,15 +290,3 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => })), }; }; - -export function getPrismaWhereUserIdFromFilter( - userId: number, - filters: NonNullable["filters"] | undefined -) { - if (!filters || !hasFilter(filters)) { - return userId; - } else if (filters.userIds?.[0] === userId) { - return userId; - } - return 0; -} diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index b7a7424552..082b5df9dd 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const filterQuerySchemaStrict = z.object({ teamIds: z.number().array().optional(), // A user can only filter by only his userId - userIds: z.number().array().max(1).optional(), + upIds: z.string().array().max(1).optional(), }); export const ZEventTypeInputSchema = z diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index fc062670ac..1e412e123e 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -33,6 +33,7 @@ export const EventTypeUpdateInput = _EventTypeModel .array( z.object({ userId: z.number(), + profileId: z.number().or(z.null()).optional(), isFixed: z.boolean().optional(), }) ) diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index adf10abd59..4c60ce5350 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -221,10 +221,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } data.hosts = { deleteMany: {}, - create: hosts.map((host) => ({ - ...host, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - })), + create: hosts.map((host) => { + const { ...rest } = host; + return { + ...rest, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + }; + }), }; } @@ -347,6 +350,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { connectedLink, updatedEventType, children, + profileId: ctx.user.profile.id, prisma: ctx.prisma, }); diff --git a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts index 52d795edaf..976005bb60 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts @@ -1,3 +1,4 @@ +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { prisma } from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -69,11 +70,13 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { }, select: { id: true, + username: true, email: true, }, }); - const userIds = foundUsersWithMatchingEmailDomain.map((user) => user.id); + const users = foundUsersWithMatchingEmailDomain; + const userIds = users.map((user) => user.id); await prisma.$transaction([ prisma.membership.updateMany({ @@ -96,6 +99,7 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { accepted: true, }, }), + prisma.user.updateMany({ where: { id: { @@ -106,6 +110,17 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { organizationId: input.orgId, }, }), + + ProfileRepository.createMany({ + users: users.map((user) => { + return { + id: user.id, + username: user.username || user.email.split("@")[0], + email: user.email, + }; + }), + organizationId: input.orgId, + }), ]); return { diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts index 73e1ecd003..aa45871ae6 100644 --- a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -1,4 +1,5 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -46,7 +47,6 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand }, }, data: { - // Remove organization relation organizationId: null, // Set username to null - to make sure there is no conflicts username: null, @@ -54,9 +54,14 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand completedOnboarding: false, }, }); + + const removeProfiles = ProfileRepository.deleteMany({ + userIds: input.userIds, + }); + // We do this in a transaction to make sure that all memberships are removed before we remove the organization relation from the user // We also do this to make sure that if one of the queries fail, the whole transaction fails - await prisma.$transaction([deleteMany, removeOrgrelation]); + await prisma.$transaction([removeProfiles, deleteMany, removeOrgrelation]); return { success: true, diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 365644fb18..02aa40dc65 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; import { createDomain } from "@calcom/lib/domainManager/organization"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; @@ -98,55 +99,67 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { } } - const createOwnerOrg = await prisma.user.create({ - data: { - username: slugify(adminUsername), - email: adminEmail, - emailVerified: new Date(), - password: hashedPassword, - // Default schedule - schedules: { - create: { - name: t("default_schedule_name"), - availability: { - createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), + const { user: createOwnerOrg, organization } = await prisma.$transaction(async (tx) => { + const organization = await tx.team.create({ + data: { + name, + ...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}), + metadata: { + ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}), + isOrganization: true, + isOrganizationVerified: true, + isOrganizationConfigured, + orgAutoAcceptEmail: emailDomain, + }, + }, + }); + + const user = await tx.user.create({ + data: { + username: slugify(adminUsername), + email: adminEmail, + emailVerified: new Date(), + password: hashedPassword, + organizationId: organization.id, + // Default schedule + schedules: { + create: { + name: t("default_schedule_name"), + availability: { + createMany: { + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + })), + }, }, }, }, - }, - organization: { - create: { - name, - ...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}), - metadata: { - ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}), - isOrganization: true, - isOrganizationVerified: true, - isOrganizationConfigured, - orgAutoAcceptEmail: emailDomain, + profiles: { + create: { + username: slugify(adminUsername), + organizationId: organization.id, + uid: ProfileRepository.generateProfileUid(), }, }, }, - }, + }); + + await tx.membership.create({ + data: { + userId: user.id, + role: MembershipRole.OWNER, + accepted: true, + teamId: organization.id, + }, + }); + return { user, organization }; }); - if (!createOwnerOrg.organizationId) throw Error("User not created"); + if (!organization.id) throw Error("User not created"); - await prisma.membership.create({ - data: { - userId: createOwnerOrg.id, - role: MembershipRole.OWNER, - accepted: true, - teamId: createOwnerOrg.organizationId, - }, - }); - - return { user: { ...createOwnerOrg, password } }; + return { user: { ...createOwnerOrg, organizationId: organization.id, password } }; } else { const language = await getTranslation(input.language ?? "en", "common"); diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index 5051223bc9..68f1109338 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -1,3 +1,4 @@ +import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -68,10 +69,10 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => throw new NoOrganizationSlugError(); } - const [teamSlugs, userSlugs] = await prisma.$transaction([ - prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }), - prisma.user.findMany({ where: { organizationId: orgId }, select: { username: true } }), - ]); + const [teamSlugs, userSlugs] = [ + await prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }), + await UserRepository.findManyByOrganization({ organizationId: orgId }), + ]; const existingSlugs = teamSlugs .flatMap((ts) => ts.slug ?? []) diff --git a/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts index 5959132f69..908cf10874 100644 --- a/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts @@ -25,7 +25,7 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) { // get requested user from database and ensure they are in the same organization const [requestedUser, membership, teams] = await prisma.$transaction([ prisma.user.findFirst({ - where: { id: input.userId, organizationId: currentUser.organizationId }, + where: { id: input.userId }, select: { id: true, email: true, diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 77484e7bee..2e881d02c4 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -1,3 +1,4 @@ +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -71,28 +72,31 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { nextCursor = nextItem?.id; } - const members = teamMembers?.map((member) => { - return { - id: member.user.id, - username: member.user.username, - email: member.user.email, - timeZone: member.user.timeZone, - role: member.role, - accepted: member.accepted, - disableImpersonation: member.user.disableImpersonation, - completedOnboarding: member.user.completedOnboarding, - teams: member.user.teams - .filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team - .map((team) => { - if (team.team.id === organizationId) return; - return { - id: team.team.id, - name: team.team.name, - slug: team.team.slug, - }; - }), - }; - }); + const members = await Promise.all( + teamMembers?.map(async (membership) => { + const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user }); + return { + id: user.id, + username: user.username, + email: user.email, + timeZone: user.timeZone, + role: membership.role, + accepted: membership.accepted, + disableImpersonation: user.disableImpersonation, + completedOnboarding: user.completedOnboarding, + teams: user.teams + .filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team + .map((team) => { + if (team.team.id === organizationId) return; + return { + id: team.team.id, + name: team.team.name, + slug: team.team.slug, + }; + }), + }; + }) || [] + ); return { rows: members || [], diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts index 0aad9bdc39..19a1bb5b55 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts @@ -2,8 +2,11 @@ import type { Prisma } from "@prisma/client"; import z from "zod"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + import type { TrpcSessionUser } from "../../../trpc"; export const ZListOtherTeamMembersSchema = z.object({ @@ -57,6 +60,22 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => { }; } + const team = await prisma.team.findUnique({ + where: { + id: input.teamId, + }, + select: { + parentId: true, + }, + }); + + if (!team) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Team not found", + }); + } + const members = await prisma.membership.findMany({ where: whereConditional, select: { @@ -71,8 +90,7 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => { name: true, email: true, avatar: true, - organization: true, - organizationId: true, + avatarUrl: true, }, }, }, @@ -87,11 +105,20 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => { nextCursor = nextItem?.id || null; } + const enrichedMemberships = []; + for (const membership of members) { + enrichedMemberships.push({ + ...membership, + user: await UserRepository.enrichUserWithItsProfile({ + user: membership.user, + }), + }); + } return { - rows: members.map((m) => { + rows: enrichedMemberships.map((m) => { return { ...m, - bookerUrl: getBookerBaseUrlSync(m.user.organization?.slug || ""), + bookerUrl: getBookerBaseUrlSync(m.user.profile?.organization?.slug || ""), }; }), nextCursor, diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts index 5ff010140e..6165b3d3cb 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts @@ -14,9 +14,7 @@ export const listOtherTeamHandler = async ({ ctx: { user } }: ListOptions) => { } const teamsInOrgIamNotPartOf = await prisma.team.findMany({ where: { - parent: { - id: user?.organization?.id, - }, + parentId: user?.organization?.id ?? null, members: { none: { userId: user.id, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index dadaac65de..1995b21420 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -13,6 +13,7 @@ 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 { UserRepository } from "@calcom/lib/server/repository/user"; import getSlots from "@calcom/lib/slots"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -226,17 +227,16 @@ export async function getDynamicEventType( }); } const dynamicEventType = getDefaultEvent(input.eventTypeSlug); + const { where } = await UserRepository._getWhereClauseForFindingUsersByUsername({ + orgSlug: isValidOrgDomain ? currentOrgDomain : null, + usernameList: Array.isArray(input.usernameList) + ? input.usernameList + : input.usernameList + ? [input.usernameList] + : [], + }); const users = await prisma.user.findMany({ - where: { - username: { - in: Array.isArray(input.usernameList) - ? input.usernameList - : input.usernameList - ? [input.usernameList] - : [], - }, - organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, - }, + where, select: { allowDynamicBooking: true, ...availabilityUserSelect, @@ -620,14 +620,10 @@ async function getUserIdFromUsername( organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean } ) { const { currentOrgDomain, isValidOrgDomain } = organizationDetails; - const user = await prisma.user.findFirst({ - where: { - username, - organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, - }, - select: { - id: true, - }, + + const [user] = await UserRepository.findUsersByUsername({ + usernameList: [username], + orgSlug: isValidOrgDomain ? currentOrgDomain : null, }); return user?.id; } diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index fa60981814..14316f73f6 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -12,6 +12,7 @@ import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; import { ZHasEditPermissionForUserSchema } from "./hasEditPermissionForUser.schema"; import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema"; import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema"; +import { ZGetListSchema } from "./list.schema"; import { ZListMembersInputSchema } from "./listMembers.schema"; import { hasTeamPlan } from "./procedures/hasTeamPlan"; import { ZPublishInputSchema } from "./publish.schema"; @@ -31,7 +32,7 @@ export const viewerTeamsRouter = router({ return handler(opts); }), // Returns teams I a member of - list: authedProcedure.query(async (opts) => { + list: authedProcedure.input(ZGetListSchema).query(async (opts) => { const handler = await importHandler(namespaced("list"), () => import("./list.handler")); return handler(opts); }), diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts index b82ca1ec84..c15af40206 100644 --- a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -1,3 +1,5 @@ +import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; +import { isOrganization } from "@calcom/lib/entityPermissionUtils"; import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; @@ -15,7 +17,7 @@ type AcceptOrLeaveOptions = { export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) => { if (input.accept) { - const membership = await prisma.membership.update({ + const teamMembership = await prisma.membership.update({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, @@ -27,13 +29,38 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) }, }); - await updateNewTeamMemberEventTypes(ctx.user.id, input.teamId); + const team = teamMembership.team; - closeComUpsertTeamUser(membership.team, ctx.user, membership.role); + if (team.parentId) { + await prisma.membership.update({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: team.parentId }, + }, + data: { + accepted: true, + }, + include: { + team: true, + }, + }); + } + + const isAnOrganization = isOrganization({ team }); + const isASubteam = team.parentId !== null; + const idOfOrganizationInContext = isAnOrganization ? team.id : isASubteam ? team.parentId : null; + const needProfileUpdate = !!idOfOrganizationInContext; + if (needProfileUpdate) { + await createAProfileForAnExistingUser({ + user: ctx.user, + organizationId: idOfOrganizationInContext, + }); + } + await updateNewTeamMemberEventTypes(ctx.user.id, input.teamId); + closeComUpsertTeamUser(team, ctx.user, teamMembership.role); } else { try { //get team owner so we can alter their subscription seat count - const teamOwner = await prisma.membership.findFirst({ + const ownerMembership = await prisma.membership.findFirst({ where: { teamId: input.teamId, role: MembershipRole.OWNER }, include: { team: true }, }); @@ -42,10 +69,21 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, + include: { + team: true, + }, }); + if (membership.team.parentId) { + await prisma.membership.delete({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: membership.team.parentId }, + }, + }); + } + // Sync Services: Close.com - if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); + if (ownerMembership) closeComUpsertTeamUser(ownerMembership.team, ctx.user, membership.role); } catch (e) { console.log(e); } diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 77e20a5f13..8ac0e08990 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -1,5 +1,6 @@ import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -47,10 +48,10 @@ const generateCheckoutSession = async ({ export const createHandler = async ({ ctx, input }: CreateOptions) => { const { user } = ctx; const { slug, name, logo } = input; - const isOrgChildTeam = !!user.organizationId; + const isOrgChildTeam = !!user.profile?.organizationId; // For orgs we want to create teams under the org - if (user.organizationId && !user.organization.isOrgAdmin) { + if (user.profile?.organizationId && !user.organization.isOrgAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" }); } @@ -58,18 +59,16 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { where: { slug: slug, // If this is under an org, check that the team doesn't already exist - parentId: isOrgChildTeam ? user.organizationId : null, + parentId: isOrgChildTeam ? user.profile?.organizationId : null, }, }); if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_url_taken" }); - if (user.organizationId) { - const nameCollisions = await prisma.user.findFirst({ - where: { - organizationId: user.organization.id, - username: slug, - }, + if (user.profile?.organizationId) { + const nameCollisions = await isSlugTakenBySomeUserInTheOrganization({ + organizationId: user.profile?.organizationId, + slug: slug, }); if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" }); @@ -104,7 +103,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { accepted: true, }, }, - ...(isOrgChildTeam && { parentId: user.organizationId }), + ...(isOrgChildTeam && { parentId: user.profile?.organizationId }), }, }); @@ -118,4 +117,17 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { }; }; +async function isSlugTakenBySomeUserInTheOrganization({ + organizationId, + slug, +}: { + organizationId: number; + slug: string; +}) { + return await ProfileRepository.findByOrgIdAndUsername({ + organizationId: organizationId, + username: slug, + }); +} + export default createHandler; diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index a4068c0d43..21f8edccad 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -16,6 +16,7 @@ type GetOptions = { export const getHandler = async ({ ctx, input }: GetOptions) => { const team = await getTeamWithMembers({ id: input.teamId, + currentOrg: ctx.user.profile?.organization ?? null, userId: ctx.user.organization?.isOrgAdmin ? undefined : ctx.user.id, includeTeamLogo: input.includeTeamLogo, isOrgView: input?.isOrg, diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts index 0d7922a0dc..99e9b4ee9d 100644 --- a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -30,11 +30,6 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai select: credentialForCalendarServiceSelect, }, // needed for getUserAvailability ...availabilityUserSelect, - organization: { - select: { - slug: true, - }, - }, }, }, }, diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 38cac6d99f..069f0cdfbb 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,9 +1,14 @@ import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; +import { isOrganization } from "@calcom/lib/entityPermissionUtils"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries"; import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations"; +import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -11,21 +16,24 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; import type { TInviteMemberInputSchema } from "./inviteMember.schema"; +import type { TeamWithParent } from "./types"; import { checkPermissions, getTeamOrThrow, getUsernameOrEmailsToInvite, getOrgConnectionInfo, getIsOrgVerified, - sendVerificationEmail, + sendSignupToOrganizationEmail, getUsersToInvite, createNewUsersConnectToOrgIfExists, - createProvisionalMemberships, + createMemberships, groupUsersByJoinability, - sendTeamInviteEmails, + sendExistingUserTeamInviteEmails, sendEmails, } from "./utils"; +const log = logger.getSubLogger({ prefix: ["inviteMember.handler"] }); + type InviteMemberOptions = { ctx: { user: NonNullable; @@ -73,6 +81,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = isInvitedToOrg: input.isOrg, team, }); + const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce( (acc, user) => ({ emails: user.email ? [...acc.emails, user.email] : acc.emails, @@ -86,6 +95,17 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = !existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail) ); + log.debug( + "inviteMemberHandler", + safeStringify({ + usernameOrEmailsToInvite, + orgConnectInfoByUsernameOrEmail, + existingUsersWithMembersips, + existingUsersEmailsAndUsernames, + newUsersEmailsOrUsernames, + }) + ); + // deal with users to create and invite to team/org if (newUsersEmailsOrUsernames.length) { await createNewUsersConnectToOrgIfExists({ @@ -96,76 +116,25 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = parentId: team.parentId, }); const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => { - return sendVerificationEmail({ + return sendSignupToOrganizationEmail({ usernameOrEmail, team, translation, ctx, input, - connectionInfo: orgConnectInfoByUsernameOrEmail[usernameOrEmail], }); }); sendEmails(sendVerifEmailsPromises); } // deal with existing users invited to join the team/org - if (existingUsersWithMembersips.length) { - const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ - existingUsersWithMembersips, - team, - }); - - // invited users can autojoin, create their memberships in org - if (autoJoinUsers.length) { - await prisma.membership.createMany({ - data: autoJoinUsers.map((userToAutoJoin) => { - const organizationRole = userToAutoJoin.teams?.[0]?.role; - return { - userId: userToAutoJoin.id, - teamId: team.id, - accepted: true, - role: - organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER - ? organizationRole - : input.role, - }; - }), - }); - - await Promise.all( - autoJoinUsers.map(async (userToAutoJoin) => { - await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id); - }) - ); - - await sendTeamInviteEmails({ - currentUserName: ctx?.user?.name, - currentUserTeamName: team?.name, - existingUsersWithMembersips: autoJoinUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - currentUserParentTeamName: team?.parent?.name, - }); - } - - // invited users cannot autojoin, create provisional memberships and send email - if (regularUsers.length) { - await createProvisionalMemberships({ - input, - invitees: regularUsers, - }); - await sendTeamInviteEmails({ - currentUserName: ctx?.user?.name, - currentUserTeamName: team?.name, - existingUsersWithMembersips: regularUsers, - language: translation, - isOrg: input.isOrg, - teamId: team.id, - currentUserParentTeamName: team?.parent?.name, - }); - } - } + await handleExistingUsersInvites({ + existingUsersWithMembersips, + team, + orgConnectInfoByUsernameOrEmail, + input, + inviter: ctx.user, + }); if (IS_TEAM_BILLING_ENABLED) { if (team.parentId) { @@ -178,3 +147,169 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = }; export default inviteMemberHandler; + +async function handleExistingUsersInvites({ + existingUsersWithMembersips, + team, + orgConnectInfoByUsernameOrEmail, + input, + inviter, +}: { + existingUsersWithMembersips: Awaited>; + team: TeamWithParent; + orgConnectInfoByUsernameOrEmail: Record; + input: { + teamId: number; + role: "ADMIN" | "MEMBER" | "OWNER"; + isOrg: boolean; + usernameOrEmail: (string | string[]) & (string | string[] | undefined); + language: string; + }; + inviter: { + name: string | null; + }; +}) { + if (!existingUsersWithMembersips.length) { + return; + } + + const translation = await getTranslation(input.language ?? "en", "common"); + if (!isOrganization({ team })) { + const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ + existingUsersWithMembersips, + team, + connectionInfoMap: orgConnectInfoByUsernameOrEmail, + }); + + log.debug( + "Inviting existing users to a team", + safeStringify({ + autoJoinUsers, + regularUsers, + }) + ); + + // invited users can autojoin, create their memberships in org + if (autoJoinUsers.length) { + await createMemberships({ + input, + invitees: autoJoinUsers, + parentId: team.parentId, + accepted: true, + }); + + await Promise.all( + autoJoinUsers.map(async (userToAutoJoin) => { + await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id); + }) + ); + + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: autoJoinUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: true, + currentUserParentTeamName: team?.parent?.name, + }); + } + + // invited users cannot autojoin, create provisional memberships and send email + if (regularUsers.length) { + await createMemberships({ + input, + invitees: regularUsers, + parentId: team.parentId, + accepted: false, + }); + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: false, + currentUserParentTeamName: team?.parent?.name, + }); + } + + const parentOrganization = team.parent; + if (parentOrganization) { + const parsedOrg = getParsedTeam(parentOrganization); + // Create profiles if needed + await Promise.all([ + autoJoinUsers + .concat(regularUsers) + .filter((u) => u.needToCreateProfile) + .map((user) => + createAProfileForAnExistingUser({ + user: user, + organizationId: parsedOrg.id, + }) + ), + ]); + } + } else { + const organization = team; + log.debug( + "Inviting existing users to an organization", + safeStringify({ + existingUsersWithMembersips, + }) + ); + + const autoJoinUsers = existingUsersWithMembersips.filter( + (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept + ); + + const regularUsers = existingUsersWithMembersips.filter( + (user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept + ); + + for (const user of existingUsersWithMembersips) { + const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept; + if (shouldAutoAccept) { + await createAProfileForAnExistingUser({ + user: user, + organizationId: organization.id, + }); + } + + await prisma.membership.create({ + data: { + userId: user.id, + teamId: team.id, + accepted: shouldAutoAccept, + role: input.role, + }, + }); + } + + // Send emails to user who auto-joined + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: autoJoinUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: true, + currentUserParentTeamName: team?.parent?.name, + }); + + // Send emails to user who need to accept invite + await sendExistingUserTeamInviteEmails({ + currentUserName: inviter.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + isAutoJoin: false, + currentUserParentTeamName: team?.parent?.name, + }); + } +} diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index d7207b8505..0e17bf90f3 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -7,14 +7,14 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import type { TeamWithParent } from "./types"; -import type { Invitee, UserWithMembership } from "./utils"; +import type { UserWithMembership } from "./utils"; import { checkPermissions, getUsernameOrEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, validateInviteeEligibility, - shouldAutoJoinIfInOrg, + getAutoJoinStatus, checkInputEmailIsValid, } from "./utils"; @@ -37,9 +37,33 @@ const mockedReturnSuccessCheckPerms = { role: MembershipRole.ADMIN, userId: 1, teamId: 1, + team: { + id: 1, + name: "Team A", + slug: null, + logo: null, + appLogo: null, + appIconLogo: null, + bio: null, + hideBranding: false, + hideBookATeamMember: false, + createdAt: new Date(), + brandColor: "#292929", + darkBrandColor: "#fafafa", + timeZone: "Europe/London", + weekStart: "Sunday", + theme: null, + timeFormat: null, + metadata: null, + parentId: null, + parent: null, + isPrivate: false, + logoUrl: "", + calVideoLogo: "", + }, }; -const mockedTeam: TeamWithParent = { +const mockedRegularTeam: TeamWithParent = { id: 1, name: "Team A", slug: null, @@ -48,6 +72,7 @@ const mockedTeam: TeamWithParent = { appIconLogo: null, bio: null, hideBranding: false, + pendingPayment: false, hideBookATeamMember: false, createdAt: new Date(), brandColor: "#292929", @@ -61,26 +86,32 @@ const mockedTeam: TeamWithParent = { parent: null, isPrivate: false, logoUrl: "", + calVideoLogo: "", }; -const mockUser: Invitee = { +const mockedSubTeam = { + ...mockedRegularTeam, + parentId: 1000, +}; + +const mockUser: UserWithMembership = { id: 4, username: "pro", email: "pro@example.com", password: "", completedOnboarding: true, identityProvider: "CAL", - organizationId: null, + profiles: [], }; const userInTeamAccepted: UserWithMembership = { ...mockUser, - teams: [{ teamId: mockedTeam.id, accepted: true, userId: mockUser.id }], + teams: [{ teamId: mockedRegularTeam.id, accepted: true, userId: mockUser.id, role: "MEMBER" }], }; const userInTeamNotAccepted: UserWithMembership = { ...mockUser, - teams: [{ teamId: mockedTeam.id, accepted: false, userId: mockUser.id }], + teams: [{ teamId: mockedRegularTeam.id, accepted: false, userId: mockUser.id, role: "MEMBER" }], }; describe("Invite Member Utils", () => { @@ -141,7 +172,7 @@ describe("Invite Member Utils", () => { orgVerified: true, usersEmail, team: { - ...mockedTeam, + ...mockedRegularTeam, parentId: 2, }, isOrg: false, @@ -154,7 +185,7 @@ describe("Invite Member Utils", () => { orgAutoAcceptDomain, usersEmail: "user@other.com", team: { - ...mockedTeam, + ...mockedRegularTeam, parentId: 2, }, isOrg: false, @@ -167,7 +198,7 @@ describe("Invite Member Utils", () => { orgAutoAcceptDomain, orgVerified: false, usersEmail, - team: { ...mockedTeam }, + team: { ...mockedRegularTeam }, isOrg: false, }); expect(result).toEqual({ orgId: undefined, autoAccept: false }); @@ -178,7 +209,7 @@ describe("Invite Member Utils", () => { orgAutoAcceptDomain, orgVerified: true, usersEmail, - team: { ...mockedTeam, parentId: null }, + team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); expect(result).toEqual({ orgId: 1, autoAccept: true }); @@ -188,7 +219,7 @@ describe("Invite Member Utils", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, usersEmail: "user@other.com", - team: { ...mockedTeam, parentId: null }, + team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); expect(result).toEqual({ orgId: undefined, autoAccept: false }); @@ -199,10 +230,10 @@ describe("Invite Member Utils", () => { orgAutoAcceptDomain, orgVerified: false, usersEmail, - team: { ...mockedTeam, parentId: null }, + team: { ...mockedRegularTeam, parentId: null }, isOrg: true, }); - expect(result).toEqual({ orgId: mockedTeam.id, autoAccept: false }); + expect(result).toEqual({ orgId: mockedRegularTeam.id, autoAccept: false }); }); }); describe("getIsOrgVerified", () => { @@ -214,7 +245,7 @@ describe("Invite Member Utils", () => { }, parent: null, }; - const result = getIsOrgVerified(true, { ...mockedTeam, ...team }); + const result = getIsOrgVerified(true, { ...mockedRegularTeam, ...team }); expect(result).toEqual({ isInOrgScope: true, orgVerified: true, @@ -226,14 +257,14 @@ describe("Invite Member Utils", () => { const team = { metadata: {}, parent: { - ...mockedTeam, + ...mockedRegularTeam, metadata: { isOrganizationVerified: false, orgAutoAcceptEmail: "example.com", }, }, }; - const result = getIsOrgVerified(false, { ...mockedTeam, ...team }); + const result = getIsOrgVerified(false, { ...mockedRegularTeam, ...team }); expect(result).toEqual({ isInOrgScope: true, orgVerified: false, @@ -246,7 +277,7 @@ describe("Invite Member Utils", () => { metadata: {}, parent: null, }; - const result = getIsOrgVerified(false, { ...mockedTeam, ...team }); + const result = getIsOrgVerified(false, { ...mockedRegularTeam, ...team }); expect(result).toEqual({ isInOrgScope: false, }); @@ -254,103 +285,167 @@ describe("Invite Member Utils", () => { }); describe("validateInviteeEligibility: Check if user can be invited to the team/org", () => { - const invitee: Invitee = { + const invitee: UserWithMembership = { ...mockUser, id: 1, username: "testuser", email: "testuser@example.com", - organizationId: null, + profiles: [], }; const isOrg = false; it("should not throw when inviting to an organization's team an existing org user", () => { - const inviteeWithOrg: Invitee = { + const inviteeWithOrg: UserWithMembership = { ...invitee, - organizationId: 2, + profiles: [getSampleProfile({ organizationId: 2 })], }; const teamWithOrg = { - ...mockedTeam, + ...mockedRegularTeam, parentId: 2, }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow(); - }); - - it("should throw a TRPCError when inviting a user who is already a member of the org", () => { - const inviteeWithOrg: Invitee = { - ...invitee, - organizationId: 1, - }; - const teamWithOrg = { - ...mockedTeam, - id: 1, - }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg)).not.toThrow(); }); it("should throw a TRPCError when inviting a user who is already a member of the team", () => { const inviteeWithOrg: UserWithMembership = { ...invitee, - organizationId: null, - teams: [{ teamId: 1, accepted: true, userId: invitee.id }], + profiles: [getSampleProfile()], + teams: [{ teamId: 1, accepted: true, userId: invitee.id, role: "ADMIN" }], }; const teamWithOrg = { - ...mockedTeam, + ...mockedRegularTeam, id: 1, }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg)).toThrow(TRPCError); }); - it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => { - const inviteeWithOrg: Invitee = { - ...invitee, - organizationId: 2, - }; - const teamWithOrg = { - ...mockedTeam, - parentId: 3, - }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); - }); - - it("should throw a TRPCError with code FORBIDDEN if the invitee already exists in Cal.com and is being invited to an organization", () => { - const isOrg = true; - expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).toThrow(TRPCError); + it("should not throw any error if the invitee already exists in Cal.com and is being invited to an organization", () => { + expect(() => validateInviteeEligibility(invitee, mockedRegularTeam)).not.toThrow(); }); it("should not throw an error if the invitee does not already belong to another organization and is not being invited to an organization", () => { - expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).not.toThrow(); + expect(() => validateInviteeEligibility(invitee, mockedRegularTeam)).not.toThrow(); }); }); describe("shouldAutoJoinIfInOrg", () => { - it("should return autoJoined: false if the user is not in the same organization as the team", async () => { - const result = await shouldAutoJoinIfInOrg({ - team: mockedTeam, + it("should return autoAccept: false if the team is a sub-team but not in the user's organization", async () => { + const result = getAutoJoinStatus({ + team: mockedSubTeam, invitee: userInTeamAccepted, + connectionInfoMap: { + [userInTeamAccepted.email]: { + orgId: mockedRegularTeam.id, + autoAccept: false, + }, + }, + }); + expect(result).toEqual({ + autoAccept: false, + needToCreateOrgMembership: true, + needToCreateProfile: false, }); - expect(result).toEqual(false); }); - it("should return autoJoined: false if the team does not have a parent organization", async () => { - const result = await shouldAutoJoinIfInOrg({ - team: { ...mockedTeam, parentId: null }, + it("should return autoAccept: true in case email is auto-acceptable even if the team is a sub-team but not in the user's organization ", async () => { + const result = getAutoJoinStatus({ + team: mockedSubTeam, invitee: userInTeamAccepted, + connectionInfoMap: { + [userInTeamAccepted.email]: { + orgId: mockedRegularTeam.id, + autoAccept: true, + }, + }, + }); + expect(result).toEqual({ + autoAccept: true, + needToCreateOrgMembership: true, + needToCreateProfile: true, }); - expect(result).toEqual(false); }); - it("should return `autoJoined: false` if team has parent organization and invitee has not accepted membership to organization", async () => { - const result = await shouldAutoJoinIfInOrg({ - team: { ...mockedTeam, parentId: mockedTeam.id }, - invitee: { ...userInTeamNotAccepted, organizationId: mockedTeam.id }, + it("should return autoAccept: false if the team is neither a sub-team, nor an organization. It is a regular team ", async () => { + const result = getAutoJoinStatus({ + team: { + ...mockedRegularTeam, + parentId: null, + }, + invitee: userInTeamAccepted, + connectionInfoMap: { + [userInTeamAccepted.email]: { + orgId: mockedRegularTeam.id, + autoAccept: false, + }, + }, + }); + expect(result).toEqual({ + autoAccept: false, + needToCreateOrgMembership: null, + needToCreateProfile: null, }); - expect(result).toEqual(false); }); - it("should return `autoJoined: true` if team has parent organization and invitee has accepted membership to organization", async () => { - const result = await shouldAutoJoinIfInOrg({ - team: { ...mockedTeam, parentId: mockedTeam.id }, - invitee: { ...userInTeamAccepted, organizationId: mockedTeam.id }, + + it("should return `autoAccept: false` if team has parent organization and invitee has not accepted membership to organization - even if email is autoAcceptable", async () => { + const result = getAutoJoinStatus({ + team: { ...mockedRegularTeam, parentId: mockedRegularTeam.id }, + invitee: { + ...userInTeamNotAccepted, + profiles: [getSampleProfile({ organizationId: mockedRegularTeam.id })], + }, + connectionInfoMap: { + [userInTeamAccepted.email]: { + orgId: mockedRegularTeam.id, + autoAccept: true, + }, + }, + }); + expect(result).toEqual({ + autoAccept: false, + needToCreateOrgMembership: false, + needToCreateProfile: false, + }); + }); + + it("should return `autoAccept: true` if team has parent organization and invitee has accepted membership to organization", async () => { + const result = getAutoJoinStatus({ + team: { ...mockedRegularTeam, parentId: mockedRegularTeam.id }, + invitee: { + ...userInTeamAccepted, + profiles: [getSampleProfile()], + }, + connectionInfoMap: { + [userInTeamAccepted.email]: { + orgId: mockedRegularTeam.id, + autoAccept: false, + }, + }, + }); + expect(result).toEqual({ + autoAccept: true, + needToCreateOrgMembership: false, + needToCreateProfile: false, }); - expect(result).toEqual(true); }); }); }); +function getSampleProfile({ organizationId }: { organizationId?: number } = {}): { + id: number; + uid: string; + userId: number; + organizationId: number; + username: string; + createdAt: Date; + updatedAt: Date; + movedFromUserId: number | null; +} { + return { + id: 1, + uid: "1", + userId: 1, + organizationId: organizationId ?? 1, + username: "", + createdAt: new Date(), + updatedAt: new Date(), + movedFromUserId: null, + }; +} diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 5768055791..2c8793231e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -1,15 +1,20 @@ import { randomBytes } from "crypto"; import type { TFunction } from "next-i18next"; -import { sendTeamInviteEmail, sendOrganizationAutoJoinEmail } from "@calcom/emails"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { sendTeamInviteEmail } from "@calcom/emails"; +import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants"; +import { isOrganization } from "@calcom/lib/entityPermissionUtils"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { Membership, Team } from "@calcom/prisma/client"; -import { Prisma, type User } from "@calcom/prisma/client"; +import { Prisma, type User as UserType } from "@calcom/prisma/client"; +import type { Profile as ProfileType } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -19,13 +24,15 @@ import type { TrpcSessionUser } from "../../../../trpc"; import { isEmail } from "../util"; import type { InviteMemberOptions, TeamWithParent } from "./types"; +const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] }); export type Invitee = Pick< - User, - "id" | "email" | "organizationId" | "username" | "password" | "identityProvider" | "completedOnboarding" + UserType, + "id" | "email" | "username" | "password" | "identityProvider" | "completedOnboarding" >; export type UserWithMembership = Invitee & { teams?: Pick[]; + profiles: ProfileType[]; }; export async function checkPermissions({ @@ -67,7 +74,7 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { if (!team) throw new TRPCError({ code: "NOT_FOUND", message: `${isOrg ? "Organization" : "Team"} not found` }); - return team; + return { ...team, metadata: teamMetadataSchema.parse(team.metadata) }; } export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) { @@ -85,11 +92,7 @@ export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | stri return emailsToInvite; } -export function validateInviteeEligibility( - invitee: UserWithMembership, - team: TeamWithParent, - isOrg: boolean -) { +export function validateInviteeEligibility(invitee: UserWithMembership, team: TeamWithParent) { const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId); if (alreadyInvited) { throw new TRPCError({ @@ -100,7 +103,10 @@ export function validateInviteeEligibility( const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId); // invitee is invited to the org's team and is already part of the organization - if (invitee.organizationId && team.parentId && invitee.organizationId === team.parentId) { + if ( + team.parentId && + UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId }) + ) { return; } @@ -112,27 +118,16 @@ export function validateInviteeEligibility( }); } - // user is invited to join a team which is not in his organization - if (invitee.organizationId && invitee.organizationId !== team.parentId) { + // user is invited to join a team in an organization where he isn't a member + if ( + !ENABLE_PROFILE_SWITCHER && + invitee.profiles.find((profile) => profile.organizationId != team.parentId) + ) { throw new TRPCError({ code: "FORBIDDEN", message: `User ${invitee.username} is already a member of another organization.`, }); } - - if (invitee && isOrg) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, - }); - } - - if (team.parentId && invitee) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `You cannot add a user that already exists in Cal.com to an organization's team. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, - }); - } } export async function getUsersToInvite({ @@ -144,9 +139,6 @@ export async function getUsersToInvite({ isInvitedToOrg: boolean; team: TeamWithParent; }): Promise { - const orgWhere = isInvitedToOrg && { - organizationId: team.id, - }; const memberships = []; if (isInvitedToOrg) { memberships.push({ teamId: team.id }); @@ -157,28 +149,35 @@ export async function getUsersToInvite({ const invitees: UserWithMembership[] = await prisma.user.findMany({ where: { - OR: [{ username: { in: usernamesOrEmails }, ...orgWhere }, { email: { in: usernamesOrEmails } }], + OR: [ + // Either it's a username in that organization + { + profiles: { + some: { + organizationId: team.id, + username: { in: usernamesOrEmails }, + }, + }, + }, + // Or it's an email + { email: { in: usernamesOrEmails } }, + ], }, select: { id: true, email: true, - organizationId: true, username: true, password: true, completedOnboarding: true, identityProvider: true, - teams: { - select: { teamId: true, userId: true, accepted: true, role: true }, - where: { - OR: memberships, - }, - }, + profiles: true, + teams: true, }, }); // Check if the users found in the database can be invited to join the team/org invitees.forEach((invitee) => { - validateInviteeEligibility(invitee, team, isInvitedToOrg); + validateInviteeEligibility(invitee, team); }); return invitees; } @@ -247,6 +246,21 @@ export async function createNewUsersConnectToOrgIfExists({ verified: true, invitedTo: input.teamId, organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org + ...(orgId + ? { + profiles: { + createMany: { + data: [ + { + uid: ProfileRepository.generateProfileUid(), + username, + organizationId: orgId, + }, + ], + }, + }, + } + : null), teams: { create: { teamId: input.teamId, @@ -274,15 +288,20 @@ export async function createNewUsersConnectToOrgIfExists({ ); } -export async function createProvisionalMemberships({ +export async function createMemberships({ input, invitees, parentId, + accepted, }: { input: InviteMemberOptions["input"]; - invitees: UserWithMembership[]; - parentId?: number; + invitees: (UserWithMembership & { + needToCreateOrgMembership: boolean | null; + })[]; + parentId: number | null; + accepted: boolean; }) { + log.debug("Creating memberships for", safeStringify({ input, invitees, parentId, accepted })); try { await prisma.membership.createMany({ data: invitees.flatMap((invitee) => { @@ -292,6 +311,7 @@ export async function createProvisionalMemberships({ data.push({ teamId: input.teamId, userId: invitee.id, + accepted, role: organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER ? organizationRole @@ -299,8 +319,9 @@ export async function createProvisionalMemberships({ }); // membership for the org - if (parentId) { + if (parentId && invitee.needToCreateOrgMembership) { data.push({ + accepted, teamId: parentId, userId: invitee.id, role: input.role as MembershipRole, @@ -310,6 +331,7 @@ export async function createProvisionalMemberships({ }), }); } catch (e) { + console.error(e); if (e instanceof Prisma.PrismaClientKnownRequestError) { // Don't throw an error if the user is already a member of the team when inviting multiple users if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") { @@ -323,18 +345,17 @@ export async function createProvisionalMemberships({ message: "Trying to invite users already members of this team / have pending invitations", }); } - logger.error("Failed to create provisional memberships", input.teamId); + logger.error("Failed to create memberships", input.teamId); } else throw e; } } -export async function sendVerificationEmail({ +export async function sendSignupToOrganizationEmail({ usernameOrEmail, team, translation, ctx, input, - connectionInfo, }: { usernameOrEmail: string; team: Awaited>; @@ -347,7 +368,6 @@ export async function sendVerificationEmail({ language: string; isOrg: boolean; }; - connectionInfo: ReturnType; }) { const token: string = randomBytes(32).toString("hex"); @@ -363,26 +383,17 @@ export async function sendVerificationEmail({ }, }, }); - if (!connectionInfo.autoAccept) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name || `${team.name}'s admin`, - to: usernameOrEmail, - teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, - isCalcomMember: false, - isOrg: input.isOrg, - parentTeamName: team?.parent?.name, - }); - } else { - await sendOrganizationAutoJoinEmail({ - language: translation, - from: ctx.user.name || `${team.name}'s admin`, - to: usernameOrEmail, - orgName: team?.parent?.name || team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, - }); - } + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name || `${team.name}'s admin`, + to: usernameOrEmail, + teamName: team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, + isCalcomMember: false, + isOrg: input.isOrg, + parentTeamName: team?.parent?.name, + isAutoJoin: false, + }); } export function getIsOrgVerified( @@ -414,37 +425,70 @@ export function getIsOrgVerified( } as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never }; } -export function shouldAutoJoinIfInOrg({ +export function getAutoJoinStatus({ team, invitee, + connectionInfoMap, }: { team: TeamWithParent; invitee: UserWithMembership; + connectionInfoMap: Record>; }) { - // Not a member of the org - if (invitee.organizationId && invitee.organizationId !== team.parentId) { - return false; - } - // team is an Org - if (!team.parentId) { - return false; + const isRegularTeam = !isOrganization({ team }) && !team.parentId; + + if (isRegularTeam) { + // There are no-auto join in regular teams ever + return { + autoAccept: false, + // Following are not relevant for regular teams + needToCreateProfile: null, + needToCreateOrgMembership: null, + }; } - const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId); + const isAutoAcceptEmail = connectionInfoMap[invitee.email].autoAccept; + const isUserMemberOfTheTeamsParentOrganization = team.parentId + ? UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId }) + : null; - if (!orgMembership?.accepted) { - return false; + if (isUserMemberOfTheTeamsParentOrganization) { + const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId); + + const isAMemberOfOrg = orgMembership?.accepted; + return { + autoAccept: isAMemberOfOrg, + // User is a member of parent organization already - So, no need to create profile and membership with Org + needToCreateProfile: false, + needToCreateOrgMembership: false, + }; } - return true; + if (isAutoAcceptEmail) { + // User is not a member of parent organization but has autoAccept email + // We need to create profile as well as membership with the Org in this case + return { + autoAccept: true, + needToCreateProfile: true, + needToCreateOrgMembership: true, + }; + } + + return { + autoAccept: false, + needToCreateProfile: false, + needToCreateOrgMembership: true, + }; } + // split invited users between ones that can autojoin and the others who cannot autojoin export const groupUsersByJoinability = ({ existingUsersWithMembersips, team, + connectionInfoMap, }: { team: TeamWithParent; existingUsersWithMembersips: UserWithMembership[]; + connectionInfoMap: Record>; }) => { const usersToAutoJoin = []; const regularUsers = []; @@ -452,14 +496,21 @@ export const groupUsersByJoinability = ({ for (let index = 0; index < existingUsersWithMembersips.length; index++) { const existingUserWithMembersips = existingUsersWithMembersips[index]; - const canAutojoin = shouldAutoJoinIfInOrg({ + const autoJoinStatus = getAutoJoinStatus({ invitee: existingUserWithMembersips, team, + connectionInfoMap, }); - canAutojoin - ? usersToAutoJoin.push(existingUserWithMembersips) - : regularUsers.push(existingUserWithMembersips); + autoJoinStatus.autoAccept + ? usersToAutoJoin.push({ + ...existingUserWithMembersips, + ...autoJoinStatus, + }) + : regularUsers.push({ + ...existingUserWithMembersips, + ...autoJoinStatus, + }); } return [usersToAutoJoin, regularUsers]; @@ -474,7 +525,7 @@ export const sendEmails = async (emailPromises: Promise[]) => { }); }; -export const sendTeamInviteEmails = async ({ +export const sendExistingUserTeamInviteEmails = async ({ existingUsersWithMembersips, language, currentUserTeamName, @@ -482,8 +533,10 @@ export const sendTeamInviteEmails = async ({ currentUserParentTeamName, isOrg, teamId, + isAutoJoin, }: { language: TFunction; + isAutoJoin: boolean; existingUsersWithMembersips: UserWithMembership[]; currentUserTeamName?: string; currentUserParentTeamName: string | undefined; @@ -493,11 +546,22 @@ export const sendTeamInviteEmails = async ({ }) => { const sendEmailsPromises = existingUsersWithMembersips.map(async (user) => { let sendTo = user.email; + if (!isEmail(user.email)) { sendTo = user.email; } + + log.debug("Sending team invite email to", safeStringify({ user, currentUserName, currentUserTeamName })); + + if (!currentUserTeamName) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "The team doesn't have a name", + }); + } + // inform user of membership by email - if (currentUserName && currentUserTeamName) { + if (currentUserTeamName) { const inviteTeamOptions = { joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, isCalcomMember: true, @@ -527,7 +591,8 @@ export const sendTeamInviteEmails = async ({ return sendTeamInviteEmail({ language, - from: currentUserName, + isAutoJoin, + from: currentUserName ?? `${currentUserTeamName}'s admin`, to: sendTo, teamName: currentUserTeamName, ...inviteTeamOptions, diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts index 23a29411ff..9824eb0d64 100644 --- a/packages/trpc/server/routers/viewer/teams/list.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -2,14 +2,16 @@ import { prisma } from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetListSchema } from "./list.schema"; type ListOptions = { ctx: { user: NonNullable; }; + input: TGetListSchema; }; -export const listHandler = async ({ ctx }: ListOptions) => { +export const listHandler = async ({ ctx, input }: ListOptions) => { const memberships = await prisma.membership.findMany({ where: { // Show all the teams this user belongs to regardless of the team being part of the user's org or not @@ -31,6 +33,7 @@ export const listHandler = async ({ ctx }: ListOptions) => { return memberships .filter((mmship) => { + if (input?.includeOrgs) return true; const metadata = teamMetadataSchema.parse(mmship.team.metadata); return !metadata?.isOrganization; }) @@ -38,6 +41,7 @@ export const listHandler = async ({ ctx }: ListOptions) => { role: membership.role, accepted: membership.accepted, ..._team, + metadata: teamMetadataSchema.parse(_team.metadata), /** To prevent breaking we only return non-email attached token here, if we have one */ inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`), })); diff --git a/packages/trpc/server/routers/viewer/teams/list.schema.ts b/packages/trpc/server/routers/viewer/teams/list.schema.ts index cb0ff5c3b5..c93e0c7345 100644 --- a/packages/trpc/server/routers/viewer/teams/list.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/list.schema.ts @@ -1 +1,9 @@ -export {}; +import { z } from "zod"; + +export const ZGetListSchema = z + .object({ + includeOrgs: z.boolean().optional(), + }) + .optional(); + +export type TGetListSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts index 8def528dca..1c12a4ec4d 100644 --- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -1,3 +1,4 @@ +import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -52,7 +53,9 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) => {} as UserMap ); - return Object.values(users); + return await Promise.all( + Object.values(users).map(async (u) => UserRepository.enrichUserWithItsProfile({ user: u })) + ); }; export default listMembersHandler; diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index 48e2e6e781..4f543321e3 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -57,13 +57,13 @@ const generateCheckoutSession = async ({ }; const publishOrganizationTeamHandler = async ({ ctx, input }: PublishOptions) => { - if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!ctx.user.profile?.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); - if (!isOrganisationAdmin(ctx.user.id, ctx.user?.organizationId)) + if (!isOrganisationAdmin(ctx.user.id, ctx.user?.profile.organizationId)) throw new TRPCError({ code: "UNAUTHORIZED" }); const createdTeam = await prisma.team.findFirst({ - where: { id: input.teamId, parentId: ctx.user.organizationId }, + where: { id: input.teamId, parentId: ctx.user.profile?.organizationId }, include: { parent: { include: { @@ -109,7 +109,7 @@ const publishOrganizationTeamHandler = async ({ ctx, input }: PublishOptions) => }; export const publishHandler = async ({ ctx, input }: PublishOptions) => { - if (ctx.user.organizationId) return publishOrganizationTeamHandler({ ctx, input }); + if (ctx.user.profile?.organizationId) return publishOrganizationTeamHandler({ ctx, input }); if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const { teamId: id } = input; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index b3171438bd..701f3cd6f7 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -1,16 +1,18 @@ import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager"; import type { PrismaClient } 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 { TRemoveMemberInputSchema } from "./removeMember.schema"; +const log = logger.getSubLogger({ prefix: ["viewer/teams/removeMember.handler"] }); type RemoveMemberOptions = { ctx: { user: NonNullable; @@ -26,8 +28,8 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = }); const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId); - const isOrgAdmin = ctx.user.organizationId - ? await isTeamAdmin(ctx.user.id, ctx.user.organizationId) + const isOrgAdmin = ctx.user.profile?.organizationId + ? await isTeamAdmin(ctx.user.id, ctx.user.profile?.organizationId) : false; if (!(isAdmin || isOrgAdmin) && ctx.user.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -47,6 +49,7 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = }, include: { user: true, + team: true, }, }); @@ -61,10 +64,14 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = }); if (input.isOrg) { + log.debug("Removing a member from the organization"); + // Deleting membership from all child teams const foundUser = await ctx.prisma.user.findUnique({ where: { id: input.memberId }, select: { + id: true, + movedToProfileId: true, email: true, password: true, username: true, @@ -75,34 +82,14 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = const orgInfo = await ctx.prisma.team.findUnique({ where: { id: input.teamId }, select: { + id: true, metadata: true, }, }); if (!foundUser || !orgInfo) throw new TRPCError({ code: "NOT_FOUND" }); - const parsedMetadata = teamMetadataSchema.parse(orgInfo.metadata); - - if ( - parsedMetadata?.isOrganization && - parsedMetadata.isOrganizationVerified && - parsedMetadata.orgAutoAcceptEmail - ) { - if (foundUser.email.endsWith(parsedMetadata.orgAutoAcceptEmail)) { - await ctx.prisma.user.delete({ - where: { id: input.memberId }, - }); - // This should cascade delete all memberships and hosts etc - return; - } - } else if ((!foundUser.username || !foundUser.password) && !foundUser.completedOnboarding) { - await ctx.prisma.user.delete({ - where: { id: input.memberId }, - }); - // This should cascade delete all memberships and hosts etc - return; - } - + // Delete all sub-team memberships where this team is the organization await ctx.prisma.membership.deleteMany({ where: { team: { @@ -112,10 +99,36 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = }, }); - await ctx.prisma.user.update({ - where: { id: membership.userId }, - data: { organizationId: null }, + const userToDeleteMembershipOf = foundUser; + + const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({ + userId: userToDeleteMembershipOf.id, + organizationId: orgInfo.id, }); + + if ( + userToDeleteMembershipOf.username && + userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id + ) { + log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username); + + await ctx.prisma.tempOrgRedirect.deleteMany({ + where: { + from: userToDeleteMembershipOf.username, + }, + }); + } + + await ctx.prisma.$transaction([ + ctx.prisma.user.update({ + where: { id: membership.userId }, + data: { organizationId: null }, + }), + ProfileRepository.delete({ + userId: membership.userId, + organizationId: orgInfo.id, + }), + ]); } // Deleted managed event types from this team from this member diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index a3390fb8eb..a9fdf03a87 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -37,6 +37,7 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption const inviteTeamOptions = { joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, isCalcomMember: true, + isAutoJoin: false, }; if (verificationToken) { diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts index e0a60bd8b9..144ec59abf 100644 --- a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts @@ -58,7 +58,6 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { avatar: true, name: true, webhooks: true, - organizationId: true, teams: { where: { accepted: true, @@ -92,7 +91,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { let userWebhooks = user.webhooks; userWebhooks = userWebhooks.filter(filterWebhooks); let webhookGroups: WebhookGroup[] = []; - const bookerUrl = await getBookerBaseUrl(user); + const bookerUrl = await getBookerBaseUrl(ctx.user.profile?.organizationId ?? null); const image = user?.username ? `${bookerUrl}/${user.username}/avatar.png` : undefined; webhookGroups.push({ diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts index 20e8691b65..6f08ff954b 100644 --- a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts @@ -26,7 +26,7 @@ export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowAction isTeamsPlan = !!hasTeamPlan; } - const hasOrgsPlan = !!user.organizationId; + const hasOrgsPlan = !!user.profile?.organizationId; const t = await getTranslation(ctx.user.locale, "common"); return getWorkflowActionOptions( diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index f449a86f99..6e26e13194 100644 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -84,7 +84,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan; - const hasOrgsPlan = IS_SELF_HOSTED || ctx.user.organizationId; + const hasOrgsPlan = IS_SELF_HOSTED || (ctx.user.profile?.organizationId ?? null); const where: Prisma.EventTypeWhereInput = {}; where.id = { diff --git a/packages/types/UserProfile.d.ts b/packages/types/UserProfile.d.ts new file mode 100644 index 0000000000..e3e3af7804 --- /dev/null +++ b/packages/types/UserProfile.d.ts @@ -0,0 +1,36 @@ +import type { Team } from "@calcom/prisma/client"; + +type OrganizationWithRequestedSlug = Pick & { + requestedSlug: string | null; +}; + +export type OrgProfile = { + id: number; + upId: string; + username: string; + organizationId: number; + organization: OrganizationWithRequestedSlug; +}; + +export type PersonalProfile = { + id: number; + upId: string; + username: string; + organizationId: null; + organization: null; +}; + +export type UserAsPersonalProfile = { + id: null; + upId: string; + username: string | null; + organizationId: null; + organization: null; +}; + +export type UserProfile = PersonalProfile | OrgProfile | UserAsPersonalProfile; + +/** + * It's a unique identifier that can refer to a user moved to a profile(with format `usr-${user.id}`) or a profile itself(Profile.id) + */ +export type UpId = string; diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index 706e977416..4abdb5b180 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -1,12 +1,16 @@ import type { User as PrismaUser, UserPermissionRole } from "@prisma/client"; import type { DefaultUser } from "next-auth"; +import type { UserProfile } from "./UserProfile"; + declare module "next-auth" { /** * Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context */ interface Session { hasValidLicense: boolean; + profileId?: number | null; + upId: string; user: User; } @@ -29,6 +33,7 @@ declare module "next-auth" { username?: PrismaUser["username"]; role?: PrismaUser["role"] | "INACTIVE_ADMIN"; locale?: string | null; + profile: UserProfile; } } @@ -38,6 +43,8 @@ declare module "next-auth/jwt" { name?: string | null; username?: string | null; email?: string | null; + upId?: string; + profileId?: number | null; role?: UserPermissionRole | "INACTIVE_ADMIN" | null; impersonatedBy?: { id: number; diff --git a/packages/ui/components/avatar/UserAvatar.test.tsx b/packages/ui/components/avatar/UserAvatar.test.tsx index e41393a337..4c50b778c5 100644 --- a/packages/ui/components/avatar/UserAvatar.test.tsx +++ b/packages/ui/components/avatar/UserAvatar.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable playwright/missing-playwright-await */ import { render } from "@testing-library/react"; -import { AVATAR_FALLBACK } from "@calcom/lib/constants"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { UserAvatar } from "./UserAvatar"; @@ -9,6 +9,13 @@ const mockUser = { name: "John Doe", username: "pro", organizationId: null, + avatarUrl: "", + profile: { + id: 1, + username: "", + organizationId: null, + organization: null, + }, }; describe("tests for UserAvatar component", () => { @@ -20,12 +27,21 @@ describe("tests for UserAvatar component", () => { }); test("It should render the organization logo if a organization is passed in", () => { + const profile: UserProfile = { + username: "", + id: 1, + upId: "1", + organizationId: 1, + organization: { + id: 1, + requestedSlug: "steve", + slug: "steve", + name: "Org1", + calVideoLogo: "", + }, + }; const { getByTestId } = render( - + ); const avatar = getByTestId("user-avatar-test"); diff --git a/packages/ui/components/avatar/UserAvatar.tsx b/packages/ui/components/avatar/UserAvatar.tsx index cc38e76020..ce5b7d68f9 100644 --- a/packages/ui/components/avatar/UserAvatar.tsx +++ b/packages/ui/components/avatar/UserAvatar.tsx @@ -1,6 +1,7 @@ import { classNames } from "@calcom/lib"; import { getOrgAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { User } from "@calcom/prisma/client"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { Avatar } from "@calcom/ui"; type Organization = { @@ -11,12 +12,14 @@ type Organization = { }; type UserAvatarProps = Omit, "alt" | "imageSrc"> & { - user: Pick; + user: Pick & { + profile: Omit; + }; + noOrganizationIndicator?: boolean; /** * Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded */ previewSrc?: string | null; - organization?: Organization | null; alt?: string | null; }; @@ -42,13 +45,14 @@ function OrganizationIndicator({ * It is aware of the user's organization to correctly show the avatar from the correct URL */ export function UserAvatar(props: UserAvatarProps) { - const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props; - - const indicator = props.organization ? ( - - ) : ( - props.indicator - ); + const { user, previewSrc = getUserAvatarUrl(user), noOrganizationIndicator, ...rest } = props; + const organization = user.profile?.organization ?? null; + const indicator = + organization && !noOrganizationIndicator ? ( + + ) : ( + props.indicator + ); return ; } diff --git a/packages/ui/components/avatar/UserAvatarGroup.tsx b/packages/ui/components/avatar/UserAvatarGroup.tsx index e9346fb401..9378e9d687 100644 --- a/packages/ui/components/avatar/UserAvatarGroup.tsx +++ b/packages/ui/components/avatar/UserAvatarGroup.tsx @@ -1,18 +1,24 @@ -import { CAL_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import type { User } from "@calcom/prisma/client"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { AvatarGroup } from "@calcom/ui"; type UserAvatarProps = Omit, "items"> & { - users: Pick[]; + users: (Pick & { + profile: Omit; + })[]; }; export function UserAvatarGroup(props: UserAvatarProps) { const { users, ...rest } = props; + return ( ({ - href: `${CAL_URL}/${user.username}?redirect=false`, + href: `${getBookerBaseUrlSync(user.profile?.organization?.slug ?? null)}/${ + user.profile?.username + }?redirect=false`, alt: user.name || "", title: user.name || "", image: getUserAvatarUrl(user), diff --git a/packages/ui/components/avatar/UserAvatarGroupWithOrg.tsx b/packages/ui/components/avatar/UserAvatarGroupWithOrg.tsx index 7fcef0b6a8..416311001a 100644 --- a/packages/ui/components/avatar/UserAvatarGroupWithOrg.tsx +++ b/packages/ui/components/avatar/UserAvatarGroupWithOrg.tsx @@ -2,10 +2,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import type { Team, User } from "@calcom/prisma/client"; +import type { UserProfile } from "@calcom/types/UserProfile"; import { AvatarGroup } from "@calcom/ui"; type UserAvatarProps = Omit, "items"> & { - users: (Pick & { bookerUrl: string })[]; + users: (Pick & { + bookerUrl: string; + profile: UserProfile; + })[]; organization: Pick; }; diff --git a/turbo.json b/turbo.json index 5cdcb2c360..87e622539e 100644 --- a/turbo.json +++ b/turbo.json @@ -371,6 +371,7 @@ "AUTH_BEARER_TOKEN_CLOUDFLARE", "CLOUDFLARE_ZONE_ID", "CLOUDFLARE_VERCEL_CNAME", - "CLOUDFLARE_DNS" + "CLOUDFLARE_DNS", + "NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER" ] }