diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 2b0873492a..3da2c942ff 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -5,6 +5,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { RedirectType } from "@calcom/prisma/client"; @@ -51,7 +52,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => select: { id: true, hideBranding: true, - parent: true, + parent: { + select: { + slug: true, + name: true, + bannerUrl: true, + organizationSettings: { + select: { + allowSEOIndexing: true, + }, + }, + }, + }, name: true, slug: true, eventTypes: { @@ -66,6 +78,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => length: true, }, }, + isOrganization: true, + organizationSettings: { + select: { + allowSEOIndexing: true, + }, + }, }, }); @@ -98,6 +116,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => eventData, }); + const organizationSettings = OrganizationRepository.utils.getOrganizationSEOSettings(team); + const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false; + return { props: { eventData: { @@ -124,6 +145,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isSEOIndexable: allowSEOIndexing, }, }; }; diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx index 8bbcbe8352..4369bba85c 100644 --- a/apps/web/lib/team/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx @@ -7,10 +7,11 @@ import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import slugify from "@calcom/lib/slugify"; import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import prisma from "@calcom/prisma"; -import type { Team } from "@calcom/prisma/client"; +import type { Team, OrganizationSettings } from "@calcom/prisma/client"; import { RedirectType } from "@calcom/prisma/client"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -20,6 +21,30 @@ import { ssrInit } from "@server/lib/ssr"; const log = logger.getSubLogger({ prefix: ["team/[slug]"] }); +function getOrgProfileRedirectToVerifiedDomain( + team: { + isOrganization: boolean; + }, + settings: Pick +) { + if (!team.isOrganization) { + return null; + } + + const verifiedDomain = OrganizationRepository.utils.getVerifiedDomain(settings); + + if (!settings.orgProfileRedirectsToVerifiedDomain || !verifiedDomain) { + return null; + } + + return { + redirect: { + permanent: false, + destination: `https://${verifiedDomain}`, + }, + }; +} + const getTheLastArrayElement = (value: ReadonlyArray | string | undefined): string | undefined => { if (value === undefined || typeof value === "string") { return value; @@ -120,6 +145,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => } as const; } + const organizationSettings = OrganizationRepository.utils.getOrganizationSettings(team); + const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false; + + const redirectToVerifiedDomain = organizationSettings + ? getOrgProfileRedirectToVerifiedDomain(team, organizationSettings) + : null; + + if (redirectToVerifiedDomain) { + return redirectToVerifiedDomain; + } + const isTeamOrParentOrgPrivate = team.isPrivate || (team.parent?.isOrganization && team.parent?.isPrivate); team.eventTypes = @@ -189,6 +225,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => markdownStrippedBio, isValidOrgDomain, currentOrgDomain, + isSEOIndexable: allowSEOIndexing, }, } as const; }; diff --git a/apps/web/modules/settings/my-account/general-view.tsx b/apps/web/modules/settings/my-account/general-view.tsx index 8207006ae1..83441bdada 100644 --- a/apps/web/modules/settings/my-account/general-view.tsx +++ b/apps/web/modules/settings/my-account/general-view.tsx @@ -180,7 +180,11 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState( !!user.allowDynamicBooking ); - const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing); + const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState( + user.organizationSettings?.allowSEOIndexing === false + ? !!user.organizationSettings?.allowSEOIndexing + : !!user.allowSEOIndexing + ); const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState( !!user.receiveMonthlyDigestEmail ); @@ -359,10 +363,11 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene /> { setIsAllowSEOIndexingChecked(checked); diff --git a/apps/web/modules/team/team-view.tsx b/apps/web/modules/team/team-view.tsx index a39a718734..245b3b5e9c 100644 --- a/apps/web/modules/team/team-view.tsx +++ b/apps/web/modules/team/team-view.tsx @@ -35,6 +35,7 @@ function TeamPage({ markdownStrippedBio, isValidOrgDomain, currentOrgDomain, + isSEOIndexable, }: PageProps) { useTheme(team.theme); const routerQuery = useRouterQuery(); @@ -175,6 +176,10 @@ function TeamPage({ image: profileImageSrc, }, }} + nextSeoProps={{ + nofollow: !isSEOIndexable, + noindex: !isSEOIndexable, + }} />
diff --git a/apps/web/modules/team/type-view.tsx b/apps/web/modules/team/type-view.tsx index 3926f67658..4a7aa849f2 100644 --- a/apps/web/modules/team/type-view.tsx +++ b/apps/web/modules/team/type-view.tsx @@ -34,6 +34,7 @@ function Type({ teamMemberEmail, crmOwnerRecordType, crmAppSlug, + isSEOIndexable, }: PageProps) { const searchParams = useSearchParams(); @@ -47,6 +48,7 @@ function Type({ isTeamEvent entity={eventData.entity} bookingData={booking} + isSEOIndexable={isSEOIndexable} /> ; } diff --git a/apps/web/modules/users/views/users-public-view.tsx b/apps/web/modules/users/views/users-public-view.tsx index fe31046a93..4291262946 100644 --- a/apps/web/modules/users/views/users-public-view.tsx +++ b/apps/web/modules/users/views/users-public-view.tsx @@ -22,7 +22,7 @@ import type { getServerSideProps } from "@server/lib/[user]/getServerSideProps"; export type PageProps = InferGetServerSidePropsType; export function UserPage(props: PageProps) { - const { users, profile, eventTypes, markdownStrippedBio, entity } = props; + const { users, profile, eventTypes, markdownStrippedBio, entity, isOrgSEOIndexable } = props; const [user] = users; //To be used when we only have a single user, not dynamic group useTheme(profile.theme); @@ -59,6 +59,13 @@ export function UserPage(props: PageProps) { const isEventListEmpty = eventTypes.length === 0; const isOrg = !!user?.profile?.organization; + + const allowSEOIndexing = isOrg + ? isOrgSEOIndexable + ? profile.allowSEOIndexing + : false + : profile.allowSEOIndexing; + return ( <> diff --git a/apps/web/playwright/organization/organization-settings.e2e.ts b/apps/web/playwright/organization/organization-settings.e2e.ts new file mode 100644 index 0000000000..53a4574ed8 --- /dev/null +++ b/apps/web/playwright/organization/organization-settings.e2e.ts @@ -0,0 +1,217 @@ +import { expect } from "@playwright/test"; +import { doOnOrgDomain } from "playwright/lib/testUtils"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { test } from "../lib/fixtures"; + +test.afterEach(({ users }) => { + users.deleteAll(); +}); + +test.describe("Organization Settings", () => { + test("Setting - 'Allow search engine indexing' inside Org profile settings", async ({ page, users }) => { + const orgMember = await users.create(undefined, { + hasTeam: true, + isOrg: true, + hasSubteam: true, + isOrgVerified: true, + isDnsSetup: true, + orgRequestedSlug: "example", + schedulingType: SchedulingType.ROUND_ROBIN, + }); + const { team: org } = await orgMember.getOrgMembership(); + const { team } = await orgMember.getFirstTeamMembership(); + const teamEvent = await orgMember.getFirstTeamEvent(team.id); + const userEvent = orgMember.eventTypes[0]; + + await orgMember.apiLogin(); + + await test.step("Set to true, enables 'Allow search engine indexing' switch on my-account/general settings", async () => { + await page.goto(`/settings/organizations/profile`); + const orgSeoIndexingSwitch = await page.getByTestId(`${org.id}-seo-indexing-switch`); + await orgSeoIndexingSwitch.click(); + await expect(orgSeoIndexingSwitch).toBeChecked(); + + await page.goto(`/settings/my-account/general`); + const mySeoIndexingSwitch = await page.getByTestId("my-seo-indexing-switch"); + await expect(mySeoIndexingSwitch).toBeEnabled(); + }); + + await test.step("Set to false, disables and sets to false 'Allow search engine indexing' switch on my-account/general settings", async () => { + await page.goto(`/settings/organizations/profile`); + const orgSeoIndexingSwitch = await page.getByTestId(`${org.id}-seo-indexing-switch`); + await orgSeoIndexingSwitch.click(); + await expect(orgSeoIndexingSwitch).toBeChecked({ checked: false }); + + await page.goto(`/settings/my-account/general`); + const mySeoIndexingSwitch = await page.getByTestId("my-seo-indexing-switch"); + await expect(mySeoIndexingSwitch).toBeChecked({ checked: false }); + await expect(mySeoIndexingSwitch).toBeDisabled(); + }); + + await test.step("Set to false, sets 'robots' meta on 'head' element to 'noindex,nofollow', for team page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/team/${team.slug}`); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("noindex,nofollow"); //disabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to false, sets 'robots' meta on 'head' element to 'noindex,nofollow', for team event page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/team/${team.slug}/${teamEvent.slug}`); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("noindex,nofollow"); //disabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to false, sets 'robots' meta on 'head' element to 'noindex,nofollow', for user page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/${orgMember.username}`); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("noindex,nofollow"); //disabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to false, sets 'robots' meta on 'head' element to 'noindex,nofollow', for user eventtype page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/${orgMember.username}/${userEvent.slug}`); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("noindex,nofollow"); //disabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to true, sets 'robots' meta on 'head' element to 'index,follow', for team page", async () => { + await page.goto(`/settings/organizations/profile`); + const orgSeoIndexingSwitch = await page.getByTestId(`${org.id}-seo-indexing-switch`); + await orgSeoIndexingSwitch.click(); + await expect(orgSeoIndexingSwitch).toBeChecked(); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/team/${team.slug}`); + await page.reload(); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("index,follow"); //enabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to true, sets 'robots' meta on 'head' element to 'index,follow', for team event page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/team/${team.slug}/${teamEvent.slug}`); + await page.reload(); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("index,follow"); //enabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to true and user's 'Allow search engine indexing' setting also set to true, user's setting is respected, for user page", async () => { + await page.goto(`/settings/my-account/general`); + const mySeoIndexingSwitch = await page.getByTestId("my-seo-indexing-switch"); + await expect(mySeoIndexingSwitch).toBeEnabled(); + await expect(mySeoIndexingSwitch).toBeChecked(); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/${orgMember.username}`); + await page.reload(); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("index,follow"); //enabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to true and user's 'Allow search engine indexing' setting also set to true, user's setting is respected, for user eventtype page", async () => { + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/${orgMember.username}/${userEvent.slug}`); + await page.reload(); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("index,follow"); //enabled seo-indexing + return { url: result.url }; + } + ); + }); + + await test.step("Set to false and user's 'Allow search engine indexing' setting was true, org's setting is prioritized, for user eventtype page", async () => { + await page.goto(`/settings/organizations/profile`); + const orgSeoIndexingSwitch = await page.getByTestId(`${org.id}-seo-indexing-switch`); + await orgSeoIndexingSwitch.click(); + await expect(orgSeoIndexingSwitch).toBeChecked({ checked: false }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async ({ page, goToUrlWithErrorHandling }) => { + const result = await goToUrlWithErrorHandling(`/${orgMember.username}/${userEvent.slug}`); + await page.reload(); + const metaTag = await page.locator('head > meta[name="robots"]'); + const metaTagValue = await metaTag.getAttribute("content"); + expect(metaTagValue).toEqual("noindex,nofollow"); //disabled seo-indexing + return { url: result.url }; + } + ); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ba1a53e756..7a031c0be0 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2819,7 +2819,9 @@ "rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.", "rr_distribution_method_balanced_title": "Load balancing", "rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.", - "exclude_emails_that_contain" : "Exclude emails that contain ...", - "exclude_emails_match_found_error_message" : "Please enter a valid work email address", + "exclude_emails_that_contain": "Exclude emails that contain ...", + "exclude_emails_match_found_error_message": "Please enter a valid work email address", + "disable_org_url_label": "Disable public organization profile and redirect", + "disable_org_url_description": "Redirects {{orgSlug}}.cal.com to {{destination}}", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts index e041660d87..dc6d96b217 100644 --- a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts @@ -219,6 +219,12 @@ async function getUserPageProps(context: GetServerSidePropsContext) { } as const; } + const allowSEOIndexing = org + ? user?.profile?.organization?.organizationSettings?.allowSEOIndexing + ? user?.allowSEOIndexing + : false + : user?.allowSEOIndexing; + const props: Props = { eventData: { id: eventData.id, @@ -230,7 +236,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { slug, trpcState: ssr.dehydrate(), isBrandingHidden: user?.hideBranding, - isSEOIndexable: user?.allowSEOIndexing, + isSEOIndexable: allowSEOIndexing, themeBasis: username, bookingUid: bookingUid ? `${bookingUid}` : null, rescheduleUid: null, diff --git a/apps/web/server/lib/[user]/getServerSideProps.ts b/apps/web/server/lib/[user]/getServerSideProps.ts index 534e3011e4..0b84c220e6 100644 --- a/apps/web/server/lib/[user]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/getServerSideProps.ts @@ -69,6 +69,7 @@ type UserPageProps = { | "currency" | "recurringEvent" >)[]; + isOrgSEOIndexable: boolean | undefined; } & EmbedProps; export const getServerSideProps: GetServerSideProps = async (context) => { @@ -194,6 +195,7 @@ export const getServerSideProps: GetServerSideProps = async (cont themeBasis: user.username, trpcState: ssr.dehydrate(), markdownStrippedBio, + isOrgSEOIndexable: org?.organizationSettings?.allowSEOIndexing, }, }; }; diff --git a/packages/features/ee/organizations/pages/settings/appearance.tsx b/packages/features/ee/organizations/pages/settings/appearance.tsx index aa81f23b04..f225bd78d4 100644 --- a/packages/features/ee/organizations/pages/settings/appearance.tsx +++ b/packages/features/ee/organizations/pages/settings/appearance.tsx @@ -43,6 +43,12 @@ const OrgAppearanceView = ({ } = themeForm; const [hideBrandingValue, setHideBrandingValue] = useState(currentOrg?.hideBranding ?? false); + const [allowSEOIndexingValue, setAllowSEOIndexingValue] = useState( + currentOrg?.organizationSettings?.allowSEOIndexing ?? false + ); + const [orgProfileRedirectsToVerifiedDomainValue, setOrgProfileRedirectsToVerifiedDomainValue] = useState( + currentOrg?.organizationSettings?.orgProfileRedirectsToVerifiedDomain ?? false + ); const brandColorsFormMethods = useForm({ defaultValues: { @@ -153,6 +159,36 @@ const OrgAppearanceView = ({ }} switchContainerClassName="mt-6" /> + + { + setAllowSEOIndexingValue(checked); + mutation.mutate({ allowSEOIndexing: checked }); + }} + switchContainerClassName="mt-6" + /> + + { + setOrgProfileRedirectsToVerifiedDomainValue(checked); + mutation.mutate({ orgProfileRedirectsToVerifiedDomain: checked }); + }} + switchContainerClassName="mt-6" + />
); }; diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index a615eb6656..042ac2a78c 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -101,6 +101,13 @@ export async function getTeamWithMembers(args: { isOrganization: true, logoUrl: true, metadata: true, + organizationSettings: { + select: { + allowSEOIndexing: true, + orgProfileRedirectsToVerifiedDomain: true, + orgAutoAcceptEmail: true, + }, + }, }, }, parentId: true, @@ -158,6 +165,13 @@ export async function getTeamWithMembers(args: { identifier: true, }, }, + organizationSettings: { + select: { + allowSEOIndexing: true, + orgProfileRedirectsToVerifiedDomain: true, + orgAutoAcceptEmail: true, + }, + }, }, } as const; diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 1224aef609..333b921a07 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -3,6 +3,7 @@ import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; +import type { OrganizationSettings } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -10,6 +11,11 @@ import { createAProfileForAnExistingUser } from "../../createAProfileForAnExisti import { getParsedTeam } from "./teamUtils"; import { UserRepository } from "./user"; +type MinimumOrganizationSettings = Pick< + OrganizationSettings, + "orgAutoAcceptEmail" | "orgProfileRedirectsToVerifiedDomain" | "allowSEOIndexing" +>; +type SEOOrganizationSettings = Pick; const orgSelect = { id: true, name: true, @@ -215,6 +221,9 @@ export class OrganizationRepository { lockEventTypeCreationForUsers: true, adminGetsNoSlotsNotification: true, isAdminReviewed: true, + allowSEOIndexing: true, + orgProfileRedirectsToVerifiedDomain: true, + orgAutoAcceptEmail: true, }, }); @@ -229,6 +238,9 @@ export class OrganizationRepository { organizationSettings: { lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers, adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification, + allowSEOIndexing: organizationSettings?.allowSEOIndexing, + orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain, + orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail, }, user: { role: membership?.role, @@ -311,4 +323,38 @@ export class OrganizationRepository { return org?.calVideoLogo; } + + static utils = { + /** + * Gets the organization setting if the team is an organization. + * If not, it gets the organization setting of the parent organization. + */ + getOrganizationSettings: (team: { + isOrganization: boolean; + organizationSettings: MinimumOrganizationSettings | null; + parent: { + organizationSettings: MinimumOrganizationSettings | null; + } | null; + }) => { + if (!team) return null; + if (team.isOrganization) return team.organizationSettings ?? null; + if (!team.parent) return null; + return team.parent.organizationSettings ?? null; + }, + getOrganizationSEOSettings: (team: { + isOrganization: boolean; + organizationSettings: SEOOrganizationSettings | null; + parent: { + organizationSettings: SEOOrganizationSettings | null; + } | null; + }) => { + if (!team) return null; + if (team.isOrganization) return team.organizationSettings ?? null; + if (!team.parent) return null; + return team.parent.organizationSettings ?? null; + }, + getVerifiedDomain(settings: Pick) { + return settings.orgAutoAcceptEmail; + }, + }; } diff --git a/packages/lib/server/repository/profile.ts b/packages/lib/server/repository/profile.ts index e063ac6815..8b1bd8e1af 100644 --- a/packages/lib/server/repository/profile.ts +++ b/packages/lib/server/repository/profile.ts @@ -36,6 +36,10 @@ const membershipSelect = Prisma.validator()({ }); const log = logger.getSubLogger({ prefix: ["repository/profile"] }); +const organizationSettingsSelect = Prisma.validator()({ + allowSEOIndexing: true, + orgProfileRedirectsToVerifiedDomain: true, +}); const organizationSelect = { id: true, slug: true, @@ -45,6 +49,12 @@ const organizationSelect = { bannerUrl: true, isPlatform: true, }; +const organizationWithSettingsSelect = { + ...organizationSelect, + organizationSettings: { + select: organizationSettingsSelect, + }, +}; export enum LookupTarget { User, @@ -373,6 +383,7 @@ export class ProfileRepository { organizationSettings: { select: { lockEventTypeCreationForUsers: true, + allowSEOIndexing: true, }, }, members: { @@ -416,7 +427,7 @@ export class ProfileRepository { select: userSelect, }, organization: { - select: organizationSelect, + select: organizationWithSettingsSelect, }, }, }); diff --git a/packages/prisma/migrations/20240813102151_add_allow_seo_indexing_and_org_profile_redirects_to_verified_domain_to_organization_settings_table/migration.sql b/packages/prisma/migrations/20240813102151_add_allow_seo_indexing_and_org_profile_redirects_to_verified_domain_to_organization_settings_table/migration.sql new file mode 100644 index 0000000000..fba6fcd5ee --- /dev/null +++ b/packages/prisma/migrations/20240813102151_add_allow_seo_indexing_and_org_profile_redirects_to_verified_domain_to_organization_settings_table/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OrganizationSettings" ADD COLUMN "allowSEOIndexing" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "orgProfileRedirectsToVerifiedDomain" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4b4bbc5c60..500111cfdb 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -469,20 +469,24 @@ model Team { } model OrganizationSettings { - id Int @id @default(autoincrement()) - organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId Int @unique - isOrganizationConfigured Boolean @default(false) + id Int @id @default(autoincrement()) + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId Int @unique + isOrganizationConfigured Boolean @default(false) // It decides if new organization members can be auto-accepted or not - isOrganizationVerified Boolean @default(false) - orgAutoAcceptEmail String - lockEventTypeCreationForUsers Boolean @default(false) - adminGetsNoSlotsNotification Boolean @default(false) + isOrganizationVerified Boolean @default(false) + // It is a domain e.g "acme.com". Any email with this domain might be auto-accepted + // Also, it is the domain to which the organization profile is redirected. + orgAutoAcceptEmail String + lockEventTypeCreationForUsers Boolean @default(false) + adminGetsNoSlotsNotification Boolean @default(false) // It decides if instance ADMIN has reviewed the organization or not. // It is used to allow super sensitive operations like 'impersonation of Org members by Org admin' - isAdminReviewed Boolean @default(false) - dSyncData DSyncData? - isAdminAPIEnabled Boolean @default(false) + isAdminReviewed Boolean @default(false) + dSyncData DSyncData? + isAdminAPIEnabled Boolean @default(false) + allowSEOIndexing Boolean @default(false) + orgProfileRedirectsToVerifiedDomain Boolean @default(false) } enum MembershipRole { diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index db1cfbf2fe..61cb3c977e 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -91,6 +91,7 @@ export const meHandler = async ({ ctx, input }: MeOptions) => { username: user.profile?.username ?? user.username ?? null, profile: user.profile ?? null, profiles: allUserEnrichedProfiles, + organizationSettings: user?.profile?.organization?.organizationSettings, }; return { diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index c8e79409ae..90478e2a9c 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -41,6 +41,14 @@ const updateOrganizationSettings = async ({ data.adminGetsNoSlotsNotification = input.adminGetsNoSlotsNotification; } + if (input.hasOwnProperty("allowSEOIndexing")) { + data.allowSEOIndexing = input.allowSEOIndexing; + } + + if (input.hasOwnProperty("orgProfileRedirectsToVerifiedDomain")) { + data.orgProfileRedirectsToVerifiedDomain = input.orgProfileRedirectsToVerifiedDomain; + } + // If no settings values have changed lets skip this update if (Object.keys(data).length === 0) return; @@ -131,7 +139,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." }); - const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata); + const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata ?? {}); const data: Prisma.TeamUpdateArgs["data"] = { logoUrl: input.logoUrl, diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 57300d2997..42434e1fdd 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -31,6 +31,8 @@ export const ZUpdateInputSchema = z.object({ lockEventTypeCreation: z.boolean().optional(), lockEventTypeCreationOptions: z.enum(["DELETE", "HIDE"]).optional(), adminGetsNoSlotsNotification: z.boolean().optional(), + allowSEOIndexing: z.boolean().optional(), + orgProfileRedirectsToVerifiedDomain: z.boolean().optional(), }); export type TUpdateInputSchema = z.infer;