From 4a6dc50909eef8ab98daf86f4756b52933b4bd4c Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Mon, 31 Jul 2023 17:27:22 -0300 Subject: [PATCH] fix: Unpublished screens (#10453) * Implementation * Changes and e2e * Reverting launch.json * Reverting org create handler * Reverting yarn.lock * DRYness and nitpicks * Default org domain to undefined * Applying zomars suggestion * Suggestions * Fixing seed and type in suggestion * Fixing types --------- Co-authored-by: zomars --- .github/workflows/e2e-app-store.yml | 1 + .github/workflows/e2e-embed-react.yml | 1 + .github/workflows/e2e-embed.yml | 1 + .github/workflows/e2e.yml | 1 + .../production-build-without-database.yml | 1 + .github/workflows/production-build.yml | 1 + apps/web/pages/[user].tsx | 43 +++++-- apps/web/pages/[user]/[type].tsx | 29 +++-- apps/web/pages/d/[link]/[slug].tsx | 8 +- .../web/pages/org/[orgSlug]/[user]/[type].tsx | 7 +- apps/web/pages/org/[orgSlug]/[user]/index.tsx | 5 +- apps/web/pages/team/[slug].tsx | 48 ++++--- apps/web/pages/team/[slug]/[type].tsx | 22 ++-- apps/web/playwright/fixtures/users.ts | 118 ++++++++++++----- apps/web/playwright/unpublished.e2e.ts | 120 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 +- packages/features/bookings/Booker/Booker.tsx | 9 +- packages/features/bookings/Booker/types.ts | 12 +- .../bookings/components/BookerSeo.tsx | 10 +- .../ee/organizations/lib/orgDomains.ts | 29 ++++- .../features/eventtypes/lib/getPublicEvent.ts | 73 +++++++---- packages/features/test/orgDomains.test.ts | 2 +- packages/lib/server/queries/teams/index.ts | 14 +- packages/prisma/seed.ts | 74 ++++++----- .../routers/viewer/teams/create.handler.ts | 2 +- .../routers/viewer/teams/get.handler.ts | 2 +- .../unpublished-entity/UnpublishedEntity.tsx | 26 ++++ .../ui/components/unpublished-entity/index.ts | 2 + packages/ui/index.tsx | 1 + 29 files changed, 503 insertions(+), 161 deletions(-) create mode 100644 apps/web/playwright/unpublished.e2e.ts create mode 100644 packages/ui/components/unpublished-entity/UnpublishedEntity.tsx create mode 100644 packages/ui/components/unpublished-entity/index.ts diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index 2e9afe0180..67c9de7c71 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -32,6 +32,7 @@ jobs: - name: Run Tests run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }} env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index af8c93659c..6a1cbbf272 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -34,6 +34,7 @@ jobs: yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }} yarn workspace @calcom/embed-react packaged:tests env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 2ddb3db308..2a61f81908 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -32,6 +32,7 @@ jobs: - name: Run Tests run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }} env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b571ad30f9..a77aeb2cc8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,6 +31,7 @@ jobs: - name: Run Tests run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }} env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml index 184c8d5fc3..32ef0785f7 100644 --- a/.github/workflows/production-build-without-database.yml +++ b/.github/workflows/production-build-without-database.yml @@ -4,6 +4,7 @@ on: workflow_call: env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml index 3053ecf465..1a9a211b9f 100644 --- a/.github/workflows/production-build.yml +++ b/.github/workflows/production-build.yml @@ -4,6 +4,7 @@ on: workflow_call: env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index f0b826836b..70cadf56ee 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -12,6 +12,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage"; @@ -24,7 +25,7 @@ import prisma from "@calcom/prisma"; import type { EventType, User } from "@calcom/prisma/client"; import { baseEventTypeSelect } from "@calcom/prisma/selects"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import { Avatar, HeadSeo } from "@calcom/ui"; +import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui"; import { Verified, ArrowRight } from "@calcom/ui/components/icon"; import type { EmbedProps } from "@lib/withEmbedSsr"; @@ -34,7 +35,7 @@ import PageWrapper from "@components/PageWrapper"; import { ssrInit } from "@server/lib/ssr"; export function UserPage(props: InferGetServerSidePropsType) { - const { users, profile, eventTypes, markdownStrippedBio } = props; + const { users, profile, eventTypes, markdownStrippedBio, entity } = props; const [user] = users; //To be used when we only have a single user, not dynamic group useTheme(profile.theme); const { t } = useLocale(); @@ -58,6 +59,15 @@ export function UserPage(props: InferGetServerSidePropsType + + + ); + } + const isEventListEmpty = eventTypes.length === 0; return ( <> @@ -206,6 +216,11 @@ export type UserPageProps = { themeBasis: string | null; markdownStrippedBio: string; safeBio: string; + entity: { + isUnpublished?: boolean; + orgSlug?: string | null; + name?: string | null; + }; eventTypes: ({ descriptionAsSafeHTML: string; metadata: z.infer; @@ -226,8 +241,10 @@ export type UserPageProps = { export const getServerSideProps: GetServerSideProps = async (context) => { const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); - + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( + context.req.headers.host ?? "", + context.params?.orgSlug + ); const usernameList = getUsernameList(context.query.user as string); const dataFetchStart = Date.now(); const usersWithoutAvatar = await prisma.user.findMany({ @@ -235,11 +252,7 @@ export const getServerSideProps: GetServerSideProps = async (cont username: { in: usernameList, }, - organization: isValidOrgDomain - ? { - slug: currentOrgDomain, - } - : null, + organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, }, select: { id: true, @@ -250,6 +263,12 @@ export const getServerSideProps: GetServerSideProps = async (cont brandColor: true, darkBrandColor: true, organizationId: true, + organization: { + select: { + slug: true, + name: true, + }, + }, theme: true, away: true, verified: true, @@ -311,6 +330,7 @@ export const getServerSideProps: GetServerSideProps = async (cont const safeBio = markdownToSafeHTML(user.bio) || ""; const markdownStrippedBio = stripMarkdown(user?.bio || ""); + const org = usersWithoutAvatar[0].organization; return { props: { @@ -321,6 +341,11 @@ export const getServerSideProps: GetServerSideProps = async (cont away: user.away, verified: user.verified, })), + entity: { + isUnpublished: org?.slug === null, + orgSlug: currentOrgDomain, + name: org?.name ?? null, + }, eventTypes, safeBio, profile, diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 67b3ac9485..a19526f462 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -6,6 +6,7 @@ import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/ import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getUsernameList } from "@calcom/lib/defaultEvents"; import slugify from "@calcom/lib/slugify"; @@ -26,7 +27,7 @@ export default function Type({ away, isBrandingHidden, rescheduleUid, - org, + entity, }: PageProps) { return (
@@ -35,7 +36,7 @@ export default function Type({ eventSlug={slug} rescheduleUid={rescheduleUid ?? undefined} hideBranding={isBrandingHidden} - org={org} + entity={entity} />
); @@ -58,7 +59,10 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( + context.req.headers.host ?? "", + context.params?.orgSlug + ); const users = await prisma.user.findMany({ where: { @@ -106,7 +110,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { return { props: { - org, + entity: eventData.entity, booking, user: usernames.join("+"), slug, @@ -124,18 +128,17 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const { user: usernames, type: slug } = paramsSchema.parse(context.params); const username = usernames[0]; const { rescheduleUid, bookingUid } = context.query; - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( + context.req.headers.host ?? "", + context.params?.orgSlug + ); const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); const user = await prisma.user.findFirst({ where: { username, - organization: isValidOrgDomain - ? { - slug: currentOrgDomain, - } - : null, + organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, }, select: { away: true, @@ -158,7 +161,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const org = isValidOrgDomain ? currentOrgDomain : null; // We use this to both prefetch the query on the server, - // as well as to check if the event exist, so we c an show a 404 otherwise. + // as well as to check if the event exist, so we can show a 404 otherwise. const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, @@ -177,7 +180,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { away: user?.away, user: username, slug, - org, + entity: eventData.entity, trpcState: ssr.dehydrate(), isBrandingHidden: user?.hideBranding, themeBasis: username, diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 5f6dff1d7a..7a7b938158 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -25,7 +25,7 @@ export default function Type({ away, isBrandingHidden, isTeamEvent, - org, + entity, }: PageProps) { return (
@@ -34,7 +34,7 @@ export default function Type({ eventSlug={slug} rescheduleUid={booking?.uid} hideBranding={isBrandingHidden} - org={org} + entity={entity} />
); @@ -132,7 +132,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { return { props: { - org, + entity: eventData.entity, booking, away: user?.away, user: username, diff --git a/apps/web/pages/org/[orgSlug]/[user]/[type].tsx b/apps/web/pages/org/[orgSlug]/[user]/[type].tsx index 1f530dac0f..c5cdf8e6d4 100644 --- a/apps/web/pages/org/[orgSlug]/[user]/[type].tsx +++ b/apps/web/pages/org/[orgSlug]/[user]/[type].tsx @@ -1,13 +1,14 @@ import type { GetServerSidePropsContext } from "next"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import prisma from "@calcom/prisma"; import PageWrapper from "@components/PageWrapper"; import type { PageProps as UserTypePageProps } from "../../../[user]/[type]"; import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[user]/[type]"; -import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]"; import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]"; +import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]"; export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const team = await prisma.team.findFirst({ @@ -16,9 +17,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { parentId: { not: null, }, - parent: { - slug: ctx.query.orgSlug as string, - }, + parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string), }, select: { id: true, diff --git a/apps/web/pages/org/[orgSlug]/[user]/index.tsx b/apps/web/pages/org/[orgSlug]/[user]/index.tsx index 59848a6b10..7174a217d7 100644 --- a/apps/web/pages/org/[orgSlug]/[user]/index.tsx +++ b/apps/web/pages/org/[orgSlug]/[user]/index.tsx @@ -1,5 +1,6 @@ import type { GetServerSidePropsContext } from "next"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import prisma from "@calcom/prisma"; import PageWrapper from "@components/PageWrapper"; @@ -16,9 +17,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { parentId: { not: null, }, - parent: { - slug: ctx.query.orgSlug as string, - }, + parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string), }, select: { id: true, diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 4dd23b61d8..812c6fcb44 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -17,7 +17,7 @@ import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; -import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui"; +import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; import { useToggleQuery } from "@lib/hooks/useToggleQuery"; @@ -49,16 +49,12 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } }, [telemetry, router.asPath]); if (isUnpublished) { + const slug = team.slug || metadata?.requestedSlug; return ( -
- } - headline={t("team_is_unpublished", { - team: teamName, - })} - description={t("team_is_unpublished_description", { - entity: metadata?.isOrganization ? t("organization").toLowerCase() : t("team").toLowerCase(), - })} +
+
); @@ -249,15 +245,21 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug; - const { isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig( + context.req.headers.host ?? "", + context.params?.orgSlug + ); const flags = await getFeatureFlagMap(prisma); - - const team = await getTeamWithMembers(undefined, slug); + const team = await getTeamWithMembers({ slug, orgSlug: currentOrgDomain }); const metadata = teamMetadataSchema.parse(team?.metadata ?? {}); - + console.warn("gSSP, team/[slug] - ", { + isValidOrgDomain, + currentOrgDomain, + ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES, + flags: JSON.stringify, + }); // Taking care of sub-teams and orgs if ( - (isValidOrgDomain && team?.parent && !!metadata?.isOrganization) || (!isValidOrgDomain && team?.parent) || (!isValidOrgDomain && !!metadata?.isOrganization) || flags["organizations"] !== true @@ -265,13 +267,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { notFound: true } as const; } - if (!team) { + if (!team || (team.parent && !team.parent.slug)) { const unpublishedTeam = await prisma.team.findFirst({ where: { - metadata: { - path: ["requestedSlug"], - equals: slug, - }, + ...(team?.parent + ? { id: team.parent.id } + : { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }), }, }); @@ -307,7 +313,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { - team: { ...serializableTeam, safeBio, members }, + team: { ...serializableTeam, safeBio, members, metadata }, themeBasis: serializableTeam.slug, trpcState: ssr.dehydrate(), markdownStrippedBio, diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 870291ecc7..2e139ee760 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -6,6 +6,7 @@ import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/ import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; @@ -17,7 +18,7 @@ import PageWrapper from "@components/PageWrapper"; export type PageProps = inferSSRProps & EmbedProps; -export default function Type({ slug, user, booking, away, isEmbed, isBrandingHidden, org }: PageProps) { +export default function Type({ slug, user, booking, away, isEmbed, isBrandingHidden, entity }: PageProps) { return (
); @@ -57,16 +58,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const { rescheduleUid } = context.query; const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( + context.req.headers.host ?? "", + context.params?.orgSlug + ); const team = await prisma.team.findFirst({ where: { - slug: teamSlug, - parent: isValidOrgDomain - ? { - slug: currentOrgDomain, - } - : null, + ...getSlugOrRequestedSlug(teamSlug), + parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, }, select: { id: true, @@ -103,7 +103,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { - org, + entity: eventData.entity, booking, away: false, user: teamSlug, diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 69797755b7..abae1ebab3 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -6,8 +6,7 @@ import { hashSync as hash } from "bcryptjs"; import dayjs from "@calcom/dayjs"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; import { prisma } from "@calcom/prisma"; -import { SchedulingType } from "@calcom/prisma/enums"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils"; import type { TimeZoneEnum } from "./types"; @@ -37,15 +36,71 @@ const seededForm = { type UserWithIncludes = PrismaType.UserGetPayload; +const createTeamEventType = async ( + user: { id: number }, + team: { id: number }, + scenario?: { + schedulingType?: SchedulingType; + teamEventTitle?: string; + teamEventSlug?: string; + } +) => { + return await prisma.eventType.create({ + data: { + team: { + connect: { + id: team.id, + }, + }, + users: { + connect: { + id: user.id, + }, + }, + owner: { + connect: { + id: user.id, + }, + }, + schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE, + title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`, + slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`, + length: 30, + }, + }); +}; + const createTeamAndAddUser = async ( - { user }: { user: { id: number; role?: MembershipRole } }, + { + user, + isUnpublished, + isOrg, + hasSubteam, + }: { + user: { id: number; username: string | null; role?: MembershipRole }; + isUnpublished?: boolean; + isOrg?: boolean; + hasSubteam?: true; + }, workerInfo: WorkerInfo ) => { + const slug = `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}`; + const data: PrismaType.TeamCreateInput = { + name: `user-id-${user.id}'s Team ${isOrg ? "Org" : "Team"}`, + }; + data.metadata = { + ...(isUnpublished ? { requestedSlug: slug } : {}), + ...(isOrg ? { isOrganization: true } : {}), + }; + data.slug = !isUnpublished ? slug : undefined; + if (isOrg && hasSubteam) { + const team = await createTeamAndAddUser({ user }, workerInfo); + await createTeamEventType(user, team); + data.children = { connect: [{ id: team.id }] }; + } + data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined; const team = await prisma.team.create({ - data: { - name: `user-id-${user.id}'s Team`, - slug: `team-${workerInfo.workerIndex}-${Date.now()}`, - }, + data, }); const { role = MembershipRole.OWNER, id: userId } = user; @@ -73,6 +128,9 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => { schedulingType?: SchedulingType; teamEventTitle?: string; teamEventSlug?: string; + isOrg?: boolean; + hasSubteam?: true; + isUnpublished?: true; } = {} ) => { const _user = await prisma.user.create({ @@ -216,30 +274,16 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => { include: userIncludes, }); if (scenario.hasTeam) { - const team = await createTeamAndAddUser({ user: { id: user.id, role: "OWNER" } }, workerInfo); - const teamEvent = await prisma.eventType.create({ - data: { - team: { - connect: { - id: team.id, - }, - }, - users: { - connect: { - id: _user.id, - }, - }, - owner: { - connect: { - id: _user.id, - }, - }, - schedulingType: scenario.schedulingType ?? SchedulingType.COLLECTIVE, - title: scenario.teamEventTitle ?? teamEventTitle, - slug: scenario.teamEventSlug ?? teamEventSlug, - length: 30, + const team = await createTeamAndAddUser( + { + user: { id: user.id, username: user.username, role: "OWNER" }, + isUnpublished: scenario.isUnpublished, + isOrg: scenario.isOrg, + hasSubteam: scenario.hasSubteam, }, - }); + workerInfo + ); + const teamEvent = await createTeamEventType(user, team, scenario); if (scenario.teammates) { // Create Teammate users for (const teammateObj of scenario.teammates) { @@ -328,6 +372,20 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { include: { team: true }, }); }, + getOrg: async () => { + return prisma.membership.findFirstOrThrow({ + where: { + userId: user.id, + team: { + metadata: { + path: ["isOrganization"], + equals: true, + }, + }, + }, + include: { team: { select: { children: true, metadata: true, name: true } } }, + }); + }, getFirstTeamEvent: async (teamId: number) => { return prisma.eventType.findFirstOrThrow({ where: { diff --git a/apps/web/playwright/unpublished.e2e.ts b/apps/web/playwright/unpublished.e2e.ts new file mode 100644 index 0000000000..e5b8de6ac6 --- /dev/null +++ b/apps/web/playwright/unpublished.e2e.ts @@ -0,0 +1,120 @@ +import { expect } from "@playwright/test"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +const title = (name: string) => `${name} is unpublished`; +const description = (entity: string) => + `This ${entity} link is currently not available. Please contact the ${entity} owner or ask them to publish it.`; +const avatar = (slug: string) => `/team/${slug}/avatar.png`; + +test.afterAll(async ({ users }) => { + await users.deleteAll(); +}); + +test.describe("Unpublished", () => { + test("Regular team profile", async ({ page, users }) => { + const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true }); + const { team } = await owner.getTeam(); + const { requestedSlug } = team.metadata as { requestedSlug: string }; + await page.goto(`/team/${requestedSlug}`); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(team.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("team")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Regular team event type", async ({ page, users }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + schedulingType: SchedulingType.COLLECTIVE, + }); + const { team } = await owner.getTeam(); + const { requestedSlug } = team.metadata as { requestedSlug: string }; + const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); + await page.goto(`/team/${requestedSlug}/${teamEventSlug}`); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(team.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("team")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Organization profile", async ({ users, page }) => { + const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); + const { team: org } = await owner.getOrg(); + const { requestedSlug } = org.metadata as { requestedSlug: string }; + await page.goto(`/org/${requestedSlug}`); + await page.waitForLoadState("networkidle"); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Organization sub-team", async ({ users, page }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + const { team: org } = await owner.getOrg(); + const { requestedSlug } = org.metadata as { requestedSlug: string }; + const [{ slug: subteamSlug }] = org.children as { slug: string }[]; + await page.goto(`/org/${requestedSlug}/team/${subteamSlug}`); + await page.waitForLoadState("networkidle"); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Organization sub-team event-type", async ({ users, page }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + const { team: org } = await owner.getOrg(); + const { requestedSlug } = org.metadata as { requestedSlug: string }; + const [{ slug: subteamSlug, id: subteamId }] = org.children as { slug: string; id: number }[]; + const { slug: subteamEventSlug } = await owner.getFirstTeamEvent(subteamId); + await page.goto(`/org/${requestedSlug}/team/${subteamSlug}/${subteamEventSlug}`); + await page.waitForLoadState("networkidle"); + + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Organization user", async ({ users, page }) => { + const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); + const { team: org } = await owner.getOrg(); + const { requestedSlug } = org.metadata as { requestedSlug: string }; + await page.goto(`/org/${requestedSlug}/${owner.username}`); + await page.waitForLoadState("networkidle"); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); + + test("Organization user event-type", async ({ users, page }) => { + const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); + const { team: org } = await owner.getOrg(); + const { requestedSlug } = org.metadata as { requestedSlug: string }; + const [{ slug: ownerEventType }] = owner.eventTypes; + await page.goto(`/org/${requestedSlug}/${owner.username}/${ownerEventType}`); + await page.waitForLoadState("networkidle"); + expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); + expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1); + expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1); + await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug)); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 769a699610..c17af7dbe8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1721,7 +1721,7 @@ "show_on_booking_page": "Show on booking page", "get_started_zapier_templates": "Get started with Zapier templates", "team_is_unpublished": "{{team}} is unpublished", - "team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them publish it.", + "team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them to publish it.", "team_member": "Team member", "a_routing_form": "A Routing Form", "form_description_placeholder": "Form Description", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index cf9d30fb16..124fb8b4d4 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -27,6 +27,7 @@ import { getQueryParam } from "./utils/query-param"; import { useBrandColors } from "./utils/use-brand-colors"; const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy")); +const UnpublishedEntity = dynamic(() => import("@calcom/ui").then((mod) => mod.UnpublishedEntity)); const DatePicker = dynamic(() => import("./components/DatePicker").then((mod) => mod.DatePicker), { ssr: false, }); @@ -38,7 +39,7 @@ const BookerComponent = ({ bookingData, hideBranding = false, isTeamEvent, - org, + entity, }: BookerProps) => { const isMobile = useMediaQuery("(max-width: 768px)"); const isTablet = useMediaQuery("(max-width: 1024px)"); @@ -98,7 +99,7 @@ const BookerComponent = ({ bookingData, layout: defaultLayout, isTeamEvent, - org, + org: entity.orgSlug, }); useEffect(() => { @@ -139,6 +140,10 @@ const BookerComponent = ({ const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false; + if (entity.isUnpublished) { + return ; + } + if (event.isSuccess && !event.data) { return ; } diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 14e3ec3043..895eac3ca3 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -5,8 +5,16 @@ import type { GetBookingType } from "../lib/get-booking"; export interface BookerProps { eventSlug: string; username: string; - // Make it optional later, once we figure out where we can possibly need to set org - org: string | null; + + /** + * Whether is a team or org, we gather basic info from both + */ + entity: { + isUnpublished?: boolean; + orgSlug?: string | null; + teamSlug?: string | null; + name?: string | null; + }; /** * If month is NOT set as a prop on the component, we expect a query parameter diff --git a/packages/features/bookings/components/BookerSeo.tsx b/packages/features/bookings/components/BookerSeo.tsx index 215527a171..f90b1cd9af 100644 --- a/packages/features/bookings/components/BookerSeo.tsx +++ b/packages/features/bookings/components/BookerSeo.tsx @@ -8,14 +8,18 @@ interface BookerSeoProps { rescheduleUid: string | undefined; hideBranding?: boolean; isTeamEvent?: boolean; - org: string | null; + entity: { + orgSlug?: string | null; + teamSlug?: string | null; + name?: string | null; + }; } export const BookerSeo = (props: BookerSeoProps) => { - const { eventSlug, username, rescheduleUid, hideBranding, isTeamEvent, org } = props; + const { eventSlug, username, rescheduleUid, hideBranding, isTeamEvent, entity } = props; const { t } = useLocale(); const { data: event } = trpc.viewer.public.event.useQuery( - { username, eventSlug, isTeamEvent, org }, + { username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null }, { refetchOnWindowFocus: false } ); diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index cc46c400b1..e82ef8d83f 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -23,11 +23,20 @@ export function getOrgSlug(hostname: string) { return null; } -export function orgDomainConfig(hostname: string) { +export function orgDomainConfig(hostname: string, fallback?: string | string[]) { const currentOrgDomain = getOrgSlug(hostname); + const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain); + if (isValidOrgDomain || !fallback) { + return { + currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null, + isValidOrgDomain, + }; + } + const fallbackOrgSlug = fallback as string; + const isValidFallbackDomain = !RESERVED_SUBDOMAINS.includes(fallbackOrgSlug); return { - currentOrgDomain, - isValidOrgDomain: currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain), + currentOrgDomain: isValidFallbackDomain ? fallbackOrgSlug : null, + isValidOrgDomain: isValidFallbackDomain, }; } @@ -39,3 +48,17 @@ export function subdomainSuffix() { export function getOrgFullDomain(slug: string, options: { protocol: boolean } = { protocol: true }) { return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`; } + +export function getSlugOrRequestedSlug(slug: string) { + return { + OR: [ + { slug }, + { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + ], + }; +} diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 2dc5bd0331..0a1c9ac67c 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations"; import { privacyFilteredLocations } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib"; import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; @@ -17,6 +18,7 @@ import { userMetadata as userMetadataSchema, bookerLayouts as bookerLayoutsSchema, BookerLayouts, + teamMetadataSchema, } from "@calcom/prisma/zod-utils"; const publicEventSelect = Prisma.validator()({ @@ -38,7 +40,24 @@ const publicEventSelect = Prisma.validator()({ currency: true, seatsPerTimeSlot: true, bookingFields: true, - team: true, + team: { + select: { + parentId: true, + metadata: true, + brandColor: true, + darkBrandColor: true, + slug: true, + name: true, + logo: true, + theme: true, + parent: { + select: { + slug: true, + name: true, + }, + }, + }, + }, successRedirectUrl: true, workflows: { include: { @@ -73,6 +92,12 @@ const publicEventSelect = Prisma.validator()({ metadata: true, brandColor: true, darkBrandColor: true, + organization: { + select: { + name: true, + slug: true, + }, + }, }, }, hidden: true, @@ -86,11 +111,7 @@ export const getPublicEvent = async ( prisma: PrismaClient ) => { const usernameList = getUsernameList(username); - const orgQuery = org - ? { - slug: org ?? null, - } - : null; + const orgQuery = org ? getSlugOrRequestedSlug(org) : null; // In case of dynamic group event, we fetch user's data and use the default event. if (usernameList.length > 1) { const users = await prisma.user.findMany({ @@ -108,6 +129,12 @@ export const getPublicEvent = async ( brandColor: true, darkBrandColor: true, theme: true, + organization: { + select: { + slug: true, + name: true, + }, + }, }, }); @@ -133,6 +160,8 @@ export const getPublicEvent = async ( defaultLayout: BookerLayouts.MONTH_VIEW, } as BookerLayoutSettings; const disableBookingTitle = !defaultEvent.isDynamic; + const unPublishedOrgUser = users.find((user) => user.organization?.slug === null); + return { ...defaultEvent, bookingFields: getBookingFieldsWithSystemFields({ ...defaultEvent, disableBookingTitle }), @@ -151,13 +180,18 @@ export const getPublicEvent = async ( firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts ), }, + entity: { + isUnpublished: unPublishedOrgUser !== undefined, + orgSlug: org, + name: unPublishedOrgUser?.organization?.name ?? null, + }, }; } const usersOrTeamQuery = isTeamEvent ? { team: { - slug: username, + ...getSlugOrRequestedSlug(username), parent: orgQuery, }, } @@ -183,6 +217,7 @@ export const getPublicEvent = async ( if (!event) return null; const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); + const teamMetadata = teamMetadataSchema.parse(event.team?.metadata || {}); const users = getUsersFromEvent(event) || (await getOwnerFromUsersArray(prisma, event.id)); if (users === null) { @@ -201,7 +236,15 @@ export const getPublicEvent = async ( // Sets user data on profile object for easier access profile: getProfileFromEvent(event), users, - orgDomain: org, + entity: { + isUnpublished: + event.team?.slug === null || + event.owner?.organization?.slug === null || + event.team?.parent?.slug === null, + orgSlug: org, + teamSlug: (event.team?.slug || teamMetadata?.requestedSlug) ?? null, + name: (event.owner?.organization?.name || event.team?.parent?.name || event.team?.name) ?? null, + }, isDynamic: false, }; }; @@ -218,20 +261,6 @@ function getProfileFromEvent(event: Event) { if (!profile) throw new Error("Event has no owner"); const username = "username" in profile ? profile.username : team?.slug; - if (!username) { - if (event.slug === "test") { - // @TODO: This is a temporary debug statement that should be removed asap. - throw new Error( - "Ciaran event error" + - JSON.stringify(team) + - " -- " + - JSON.stringify(hosts) + - " -- " + - JSON.stringify(owner) - ); - } - throw new Error("Event has no username/team slug"); - } const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday"; const basePath = team ? `/team/${username}` : `/${username}`; const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); diff --git a/packages/features/test/orgDomains.test.ts b/packages/features/test/orgDomains.test.ts index 86c518819b..c8819cad51 100644 --- a/packages/features/test/orgDomains.test.ts +++ b/packages/features/test/orgDomains.test.ts @@ -26,7 +26,7 @@ describe("Org Domains Utils", () => { it("should return a non valid org domain", () => { setupEnvs(); expect(orgDomainConfig("app.cal.com")).toEqual({ - currentOrgDomain: "app", + currentOrgDomain: null, isValidOrgDomain: false, }); }); diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 0ce3a231ae..6d10bc3ffa 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -1,5 +1,6 @@ import { Prisma } from "@prisma/client"; +import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -8,7 +9,13 @@ import { WEBAPP_URL } from "../../../constants"; export type TeamWithMembers = Awaited>; -export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) { +export async function getTeamWithMembers(args: { + id?: number; + slug?: string; + userId?: number; + orgSlug?: string | null; +}) { + const { id, slug, userId, orgSlug } = args; const userSelect = Prisma.validator()({ username: true, email: true, @@ -92,6 +99,11 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu const where: Prisma.TeamFindFirstArgs["where"] = {}; if (userId) where.members = { some: { userId } }; + if (orgSlug) { + where.parent = getSlugOrRequestedSlug(orgSlug); + } else { + where.parentId = null; + } if (id) where.id = id; if (slug) where.slug = slug; diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 15c9319939..406da7b001 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -1,5 +1,6 @@ import type { Prisma, UserPermissionRole } from "@prisma/client"; import { uuid } from "short-uuid"; +import type z from "zod"; import dailyMeta from "@calcom/app-store/dailyvideo/_metadata"; import googleMeetMeta from "@calcom/app-store/googlevideo/_metadata"; @@ -11,8 +12,12 @@ import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; import prisma from "."; import mainAppStore from "./seed-app-store"; +import type { teamMetadataSchema } from "./zod-utils"; -async function createUserAndEventType(opts: { +async function createUserAndEventType({ + user, + eventTypes = [], +}: { user: { email: string; password: string; @@ -23,20 +28,20 @@ async function createUserAndEventType(opts: { role?: UserPermissionRole; theme?: "dark" | "light"; }; - eventTypes: Array< - Prisma.EventTypeCreateInput & { + eventTypes?: Array< + Prisma.EventTypeUncheckedCreateInput & { _bookings?: Prisma.BookingCreateInput[]; } >; }) { const userData = { - ...opts.user, - password: await hashPassword(opts.user.password), + ...user, + password: await hashPassword(user.password), emailVerified: new Date(), - completedOnboarding: opts.user.completedOnboarding ?? true, + completedOnboarding: user.completedOnboarding ?? true, locale: "en", schedules: - opts.user.completedOnboarding ?? true + user.completedOnboarding ?? true ? { create: { name: "Working Hours", @@ -50,20 +55,20 @@ async function createUserAndEventType(opts: { : undefined, }; - const user = await prisma.user.upsert({ - where: { email_username: { email: opts.user.email, username: opts.user.username } }, + const theUser = await prisma.user.upsert({ + where: { email_username: { email: user.email, username: user.username } }, update: userData, create: userData, }); console.log( - `šŸ‘¤ Upserted '${opts.user.username}' with email "${opts.user.email}" & password "${opts.user.password}". Booking page šŸ‘‰ ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${opts.user.username}` + `šŸ‘¤ Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page šŸ‘‰ ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}` ); - for (const eventTypeInput of opts.eventTypes) { + for (const eventTypeInput of eventTypes) { const { _bookings: bookingFields = [], ...eventTypeData } = eventTypeInput; - eventTypeData.userId = user.id; - eventTypeData.users = { connect: { id: user.id } }; + eventTypeData.userId = theUser.id; + eventTypeData.users = { connect: { id: theUser.id } }; const eventType = await prisma.eventType.findFirst({ where: { @@ -98,13 +103,13 @@ async function createUserAndEventType(opts: { ...bookingInput, user: { connect: { - email: opts.user.email, + email: user.email, }, }, attendees: { create: { - email: opts.user.email, - name: opts.user.name, + email: user.email, + name: user.name, timeZone: "Europe/London", }, }, @@ -124,15 +129,32 @@ async function createUserAndEventType(opts: { } } - return user; + return theUser; } async function createTeamAndAddUsers( teamInput: Prisma.TeamCreateInput, - users: { id: number; username: string; role?: MembershipRole }[] + users: { id: number; username: string; role?: MembershipRole }[] = [] ) { + const checkUnpublishedTeam = async (slug: string) => { + return await prisma.team.findFirst({ + where: { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + }); + }; const createTeam = async (team: Prisma.TeamCreateInput) => { try { + const requestedSlug = (team.metadata as z.infer)?.requestedSlug; + if (requestedSlug) { + const unpublishedTeam = await checkUnpublishedTeam(requestedSlug); + if (unpublishedTeam) { + throw Error("Unique constraint failed on the fields"); + } + } return await prisma.team.create({ data: { ...team, @@ -168,6 +190,8 @@ async function createTeamAndAddUsers( }); console.log(`\tšŸ‘¤ Added '${teamInput.name}' membership for '${username}' with role '${role}'`); } + + return team; } async function main() { @@ -178,7 +202,6 @@ async function main() { username: "delete-me", name: "delete-me", }, - eventTypes: [], }); await createUserAndEventType({ @@ -189,7 +212,6 @@ async function main() { name: "onboarding", completedOnboarding: false, }, - eventTypes: [], }); await createUserAndEventType({ @@ -279,19 +301,19 @@ async function main() { title: "Zoom Event", slug: "zoom", length: 60, - locations: [{ type: zoomMeta.appData?.location.type }], + locations: [{ type: zoomMeta.appData?.location?.type }], }, { title: "Daily Event", slug: "daily", length: 60, - locations: [{ type: dailyMeta.appData?.location.type }], + locations: [{ type: dailyMeta.appData?.location?.type }], }, { title: "Google Meet", slug: "google-meet", length: 60, - locations: [{ type: googleMeetMeta.appData?.location.type }], + locations: [{ type: googleMeetMeta.appData?.location?.type }], }, { title: "Yoga class", @@ -503,7 +525,6 @@ async function main() { username: "teamfree", name: "Team Free Example", }, - eventTypes: [], }); const proUserTeam = await createUserAndEventType({ @@ -513,7 +534,6 @@ async function main() { username: "teampro", name: "Team Pro Example", }, - eventTypes: [], }); await createUserAndEventType({ @@ -525,7 +545,6 @@ async function main() { name: "Admin Example", role: "ADMIN", }, - eventTypes: [], }); const pro2UserTeam = await createUserAndEventType({ @@ -535,7 +554,6 @@ async function main() { username: "teampro2", name: "Team Pro Example 2", }, - eventTypes: [], }); const pro3UserTeam = await createUserAndEventType({ @@ -545,7 +563,6 @@ async function main() { username: "teampro3", name: "Team Pro Example 3", }, - eventTypes: [], }); const pro4UserTeam = await createUserAndEventType({ @@ -555,7 +572,6 @@ async function main() { username: "teampro4", name: "Team Pro Example 4", }, - eventTypes: [], }); await createTeamAndAddUsers( diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index f81397fe0a..c6d3f61fba 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -28,7 +28,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { where: { slug: slug, // If this is under an org, check that the team doesn't already exist - ...(isOrgChildTeam && { parentId: user.organizationId }), + parentId: isOrgChildTeam ? user.organizationId : null, }, }); diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index 75de7fbfee..623e75ee38 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -15,7 +15,7 @@ type GetOptions = { }; export const getHandler = async ({ ctx, input }: GetOptions) => { - const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); + const team = await getTeamWithMembers({ id: input.teamId, userId: ctx.user.id }); if (!team) { throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); diff --git a/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx b/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx new file mode 100644 index 0000000000..054ce6cde2 --- /dev/null +++ b/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx @@ -0,0 +1,26 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { EmptyScreen, Avatar } from "@calcom/ui"; + +export type UnpublishedEntityProps = { + teamSlug?: string | null; + orgSlug?: string | null; + name?: string | null; +}; + +export function UnpublishedEntity(props: UnpublishedEntityProps) { + const { t } = useLocale(); + const slug = props.orgSlug || props.teamSlug; + return ( +
+ } + headline={t("team_is_unpublished", { + team: props.name, + })} + description={t("team_is_unpublished_description", { + entity: props.orgSlug ? t("organization").toLowerCase() : t("team").toLowerCase(), + })} + /> +
+ ); +} diff --git a/packages/ui/components/unpublished-entity/index.ts b/packages/ui/components/unpublished-entity/index.ts new file mode 100644 index 0000000000..9c5f7c4f2b --- /dev/null +++ b/packages/ui/components/unpublished-entity/index.ts @@ -0,0 +1,2 @@ +export { UnpublishedEntity } from "./UnpublishedEntity"; +export type { UnpublishedEntityProps } from "./UnpublishedEntity"; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index bc368ac9f5..f99efc5feb 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -83,6 +83,7 @@ export type { AlertProps } from "./components/alert"; export { Credits } from "./components/credits"; export { Divider, VerticalDivider } from "./components/divider"; export { EmptyScreen } from "./components/empty-screen"; +export { UnpublishedEntity } from "./components/unpublished-entity"; export { List, ListItem, ListItemText, ListItemTitle, ListLinkItem } from "./components/list"; export type { ListItemProps, ListProps } from "./components/list"; export { HeadSeo } from "./components/head-seo";