feat: admin org list now shows all, dns check added (#10875)

* chore: admin org list now shows all, dns check added

* Updating email template

* type fixes
pull/10902/head^2
Leo Giovanetti 2023-08-23 18:01:12 -03:00 committed by GitHub
parent 68ec946c59
commit e6e6f09547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 432 additions and 147 deletions

View File

@ -1,9 +1,9 @@
import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage";
import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = UnverifiedOrgsPage as CalPageWrapper;
const Page = AdminOrgsPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -2010,6 +2010,20 @@
"member_removed": "Member removed",
"my_availability": "My Availability",
"team_availability": "Team Availability",
"admin_org_notification_email_subject": "New organization created: pending action",
"hi_admin": "Hi Administrator",
"admin_org_notification_email_title": "An organization requires DNS setup",
"admin_org_notification_email_body_part1": "An organization with slug \"{{orgSlug}}\" was created.<br /><br />Please be sure to configure your DNS registry to point the subdomain corresponding to the new organization to where the main app is running. Otherwise the organization will not work.<br /><br />Here are just the very basic options to configure a subdomain to point to their app so it loads the organization profile page.<br /><br />You can do it either with the A Record:",
"admin_org_notification_email_body_part2": "Or the CNAME record:",
"admin_org_notification_email_body_part3": "Once you configure the subdomain, please mark the DNS configuration as done in Organizations Admin Settings.",
"admin_org_notification_email_cta": "Go to Organizations Admin Settings",
"org_has_been_processed": "Org has been processed",
"org_error_processing": "There has been an error processing this organization",
"orgs_page_description": "A list of all organizations. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation.",
"unverified": "Unverified",
"dns_missing": "DNS Missing",
"mark_dns_configured": "Mark as DNS configured",
"value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"

View File

@ -9,6 +9,8 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import type { OrganizationNotification } from "./templates/admin-organization-notification";
import AdminOrganizationNotification from "./templates/admin-organization-notification";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
@ -376,3 +378,7 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow
export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => {
await sendEmail(() => new OrganizationEmailVerification(sendOrgInput));
};
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
await sendEmail(() => new AdminOrganizationNotification(input));
};

View File

@ -0,0 +1,118 @@
import { Trans, type TFunction } from "next-i18next";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
type AdminOrganizationNotification = {
language: TFunction;
orgSlug: string;
webappIPAddress: string;
};
const dnsTable = (type: string, name: string, value: string, t: TFunction) => (
<table
role="presentation"
border={0}
cellSpacing="0"
cellPadding="0"
style={{
verticalAlign: "top",
marginTop: "10px",
borderRadius: "6px",
borderCollapse: "separate",
border: "solid black 1px",
}}
width="100%">
<tbody>
<thead>
<tr
style={{
backgroundColor: "black",
color: "white",
fontSize: "14px",
lineHeight: "24px",
}}>
<td
align="center"
width="33%"
style={{ borderTopLeftRadius: "5px", borderRight: "1px solid white" }}>
{t("type")}
</td>
<td align="center" width="33%" style={{ borderRight: "1px solid white" }}>
{t("name")}
</td>
<td align="center" style={{ borderTopRightRadius: "5px" }}>
{t("value")}
</td>
</tr>
</thead>
<tr style={{ lineHeight: "24px" }}>
<td align="center" style={{ borderBottomLeftRadius: "5px", borderRight: "1px solid black" }}>
{type}
</td>
<td align="center" style={{ borderRight: "1px solid black" }}>
{name}
</td>
<td align="center" style={{ borderBottomRightRadius: "5px" }}>
{value}
</td>
</tr>
</tbody>
</table>
);
export const AdminOrganizationNotificationEmail = ({
orgSlug,
webappIPAddress,
language,
}: AdminOrganizationNotification) => {
const webAppUrl = WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "");
return (
<BaseEmailHtml
subject={language("admin_org_notification_email_subject", { appName: APP_NAME })}
callToAction={
<CallToAction
label={language("admin_org_notification_email_cta")}
href={`${WEBAPP_URL}/settings/admin/organizations`}
endIconName="white-arrow-right"
/>
}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "38px",
}}>
<>{language("admin_org_notification_email_title")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{language("hi_admin")}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<Trans i18nKey="admin_org_notification_email_body_part1" t={language} values={{ orgSlug }}>
An organization with slug {`"${orgSlug}"`} was created.
<br />
<br />
Please be sure to configure your DNS registry to point the subdomain corresponding to the new
organization to where the main app is running. Otherwise the organization will not work.
<br />
<br />
Here are just the very basic options to configure a subdomain to point to their app so it loads the
organization profile page.
<br />
<br />
You can do it either with the A Record:
</Trans>
</p>
{dnsTable("A", orgSlug, webappIPAddress, language)}
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{language("admin_org_notification_email_body_part2")}
</p>
{dnsTable("CNAME", orgSlug, webAppUrl, language)}
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{language("admin_org_notification_email_body_part3")}
</p>
</BaseEmailHtml>
);
};

View File

@ -29,3 +29,4 @@ export * from "@calcom/app-store/routing-forms/emails/components";
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";

View File

@ -0,0 +1,43 @@
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type OrganizationNotification = {
t: TFunction;
instanceAdmins: { email: string }[];
ownerEmail: string;
orgSlug: string;
webappIPAddress: string;
};
export default class AdminOrganizationNotification extends BaseEmail {
input: OrganizationNotification;
constructor(input: OrganizationNotification) {
super();
this.name = "SEND_ADMIN_ORG_NOTIFICATION";
this.input = input;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: this.input.instanceAdmins.map((admin) => admin.email).join(","),
subject: `${this.input.t("admin_org_notification_email_subject")}`,
html: renderEmail("AdminOrganizationNotificationEmail", {
orgSlug: this.input.orgSlug,
webappIPAddress: this.input.webappIPAddress,
language: this.input.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `${this.input.t("hi_admin")}, ${this.input.t("admin_org_notification_email_title").toLowerCase()}
${this.input.t("admin_org_notification_email_body")}`.trim();
}
}

View File

@ -0,0 +1,145 @@
import NoSSR from "@calcom/core/components/NoSSR";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, DropdownActions, showToast, Table, Badge } from "@calcom/ui";
import { X, Check, CheckCheck } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
function AdminOrgTable() {
const { t } = useLocale();
const utils = trpc.useContext();
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
onSuccess: async () => {
showToast(t("org_has_been_processed"), "success");
await utils.viewer.organizations.adminGetAll.invalidate();
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const updateMutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
showToast(t("org_has_been_processed"), "success");
await utils.viewer.organizations.adminGetAll.invalidate();
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("owner")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">{t("edit")}</span>
</ColumnTitle>
</Header>
<Body>
{data.map((org) => (
<Row key={org.id}>
<Cell widthClassNames="w-auto">
<div className="text-subtle font-medium">
<span className="text-default">{org.name}</span>
<br />
<span className="text-muted">
{org.slug}.{extractDomainFromWebsiteUrl}
</span>
</div>
</Cell>
<Cell widthClassNames="w-auto">
<span className="break-all">{org.members[0].user.email}</span>
</Cell>
<Cell>
<div className="space-x-2">
{!org.metadata?.isOrganizationVerified && <Badge variant="blue">{t("unverified")}</Badge>}
{!org.metadata?.isOrganizationConfigured && <Badge variant="red">{t("dns_missing")}</Badge>}
</div>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
{(!org.metadata?.isOrganizationVerified || !org.metadata?.isOrganizationConfigured) && (
<DropdownActions
actions={[
...(!org.metadata?.isOrganizationVerified
? [
{
id: "accept",
label: t("accept"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
status: "ACCEPT",
});
},
icon: Check,
},
{
id: "reject",
label: t("reject"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
status: "DENY",
});
},
icon: X,
},
]
: []),
...(!org.metadata?.isOrganizationConfigured
? [
{
id: "dns",
label: t("mark_dns_configured"),
onClick: () => {
updateMutation.mutate({
orgId: org.id,
metadata: {
isOrganizationConfigured: true,
},
});
},
icon: CheckCheck,
},
]
: []),
]}
/>
)}
</div>
</Cell>
</Row>
))}
</Body>
</Table>
</div>
);
}
const AdminOrgList = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta title={t("organizations")} description={t("orgs_page_description")} />
<NoSSR>
<AdminOrgTable />
</NoSSR>
</LicenseRequired>
);
};
AdminOrgList.getLayout = getLayout;
export default AdminOrgList;

View File

@ -1,108 +0,0 @@
import NoSSR from "@calcom/core/components/NoSSR";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { trpc } from "@calcom/trpc/react";
import { Meta } from "@calcom/ui";
import { DropdownActions, showToast, Table } from "@calcom/ui";
import { Check, X } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
function UnverifiedOrgTable() {
const utils = trpc.useContext();
const [data] = trpc.viewer.organizations.adminGetUnverified.useSuspenseQuery();
const mutation = trpc.viewer.organizations.adminVerify.useMutation({
onSuccess: async () => {
showToast("Org has been processed", "success");
await utils.viewer.organizations.adminGetUnverified.invalidate();
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error processing this org.", "error");
},
});
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">Organization</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">Owner</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">Edit</span>
</ColumnTitle>
</Header>
<Body>
{data.map((org) => (
<Row key={org.id}>
<Cell widthClassNames="w-auto">
<div className="text-subtle font-medium">
<span className="text-default">{org.name}</span>
<br />
<span className="text-muted">
{org.slug}.{extractDomainFromWebsiteUrl}
</span>
</div>
</Cell>
<Cell widthClassNames="w-auto">
<span className="break-all">{org.members[0].user.email}</span>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
<DropdownActions
actions={[
{
id: "accept",
label: "Accept",
onClick: () => {
mutation.mutate({
orgId: org.id,
status: "ACCEPT",
});
},
icon: Check,
},
{
id: "reject",
label: "Reject",
onClick: () => {
mutation.mutate({
orgId: org.id,
status: "DENY",
});
},
icon: X,
},
]}
/>
</div>
</Cell>
</Row>
))}
</Body>
</Table>
</div>
);
}
const UnverifiedOrgList = () => {
return (
<LicenseRequired>
<Meta
title="Organizations"
description="A list of all organizations that need verification based on their email domain. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation."
/>
<NoSSR>
<UnverifiedOrgTable />
</NoSSR>
</LicenseRequired>
);
};
UnverifiedOrgList.getLayout = getLayout;
export default UnverifiedOrgList;

View File

@ -0,0 +1,32 @@
import type { SomeZodObject, z } from "zod";
import objectKeys from "./objectKeys";
/**
* The intention behind these helpers is to make it easier to work with metadata.
* @param schema This is the zod schema that you want to use to parse the metadata
* @param rawMetadata This is the metadata that you want to parse
* @returns An object with the parsed metadata, a get function to get a specific key of the metadata, and a mergeMetadata function to merge new metadata with the old one
* @example
* const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema, team.metadata);
* const newMetadata = mergeMetadata({ someKey: "someValue" });
* prisma.team.update({ ..., data: { metadata: newMetadata } });
*/
export function getMetadataHelpers<T extends SomeZodObject>(schema: T, rawMetadata: unknown) {
const metadata = schema.parse(rawMetadata) as z.infer<T>;
return {
metadata,
get: (key: keyof z.infer<T>) => metadata[key],
/** This method prevents overwriting the metadata fields that you don't want to change. */
mergeMetadata: (newMetadata: z.infer<T>) => {
const newMetadataToReturn = { ...metadata, ...newMetadata };
// We check for each key of newMetadata and if it's explicitly undefined, we delete it from newMetadataToReturn
objectKeys(newMetadata).forEach((key) => {
if (newMetadata[key] === undefined) {
delete newMetadataToReturn[key];
}
});
return newMetadataToReturn as z.infer<T>;
},
};
}

View File

@ -328,6 +328,7 @@ export const teamMetadataSchema = z
subscriptionItemId: z.string().nullable(),
isOrganization: z.boolean().nullable(),
isOrganizationVerified: z.boolean().nullable(),
isOrganizationConfigured: z.boolean().nullable(),
orgAutoAcceptEmail: z.string().nullable(),
})
.partial()

View File

@ -63,11 +63,8 @@ export const viewerOrganizationsRouter = router({
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
return handler(opts);
}),
adminGetUnverified: authedAdminProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("adminGetUnverified"),
() => import("./adminGetUnverified.handler")
);
adminGetAll: authedAdminProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("adminGetAll"), () => import("./adminGetAll.handler"));
return handler(opts);
}),
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => {

View File

@ -1,15 +1,16 @@
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "../../../trpc";
type AdminGetUnverifiedOptions = {
type AdminGetAllOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptions) => {
const unVerifiedTeams = await prisma.team.findMany({
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetAllOptions) => {
const allOrgs = await prisma.team.findMany({
where: {
AND: [
{
@ -18,18 +19,13 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio
equals: true,
},
},
{
metadata: {
path: ["isOrganizationVerified"],
equals: false,
},
},
],
},
select: {
id: true,
name: true,
slug: true,
metadata: true,
members: {
where: {
role: "OWNER",
@ -47,7 +43,7 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio
},
});
return unVerifiedTeams;
return allOrgs.map((org) => ({ ...org, metadata: teamMetadataSchema.parse(org.metadata) }));
};
export default adminGetUnverifiedHandler;

View File

@ -1,19 +1,21 @@
import { createHash } from "crypto";
import { lookup } from "dns";
import { totp } from "otplib";
import { sendOrganizationEmailVerification } from "@calcom/emails";
import { sendAdminOrganizationNotification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import {
IS_CALCOM,
IS_TEAM_BILLING_ENABLED,
RESERVED_SUBDOMAINS,
IS_PRODUCTION,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@ -27,6 +29,15 @@ type CreateOptions = {
input: TCreateInputSchema;
};
const getIPAddress = async (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
lookup(url, (err, address) => {
if (err) reject(err);
resolve(address);
});
});
};
const vercelCreateDomain = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,
@ -43,12 +54,10 @@ const vercelCreateDomain = async (domain: string) => {
const data = await response.json();
// Domain is already owned by another team but you can request delegation to access it
if (data.error?.code === "forbidden")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" });
if (data.error?.code === "forbidden") return false;
// Domain is already being used by a different project
if (data.error?.code === "domain_taken")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
if (data.error?.code === "domain_taken") return false;
return true;
};
@ -85,9 +94,35 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
const t = await getTranslation(ctx.user.locale ?? "en", "common");
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
let isOrganizationConfigured = false;
if (check === false) {
if (IS_CALCOM) await vercelCreateDomain(slug);
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.VERCEL) {
// We only want to proceed to register the subdomain for the org in Vercel
// within a Vercel context
isOrganizationConfigured = await vercelCreateDomain(slug);
} else {
// Otherwise, we proceed to send an administrative email to admins regarding
// the need to configure DNS registry to support the newly created org
const instanceAdmins = await prisma.user.findMany({
where: { role: UserPermissionRole.ADMIN },
select: { email: true },
});
if (instanceAdmins.length) {
await sendAdminOrganizationNotification({
instanceAdmins,
orgSlug: slug,
ownerEmail: adminEmail,
webappIPAddress: await getIPAddress(
WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "")
),
t,
});
} else {
console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators");
}
}
const createOwnerOrg = await prisma.user.create({
data: {
@ -118,6 +153,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),
isOrganization: true,
isOrganizationVerified: false,
isOrganizationConfigured,
orgAutoAcceptEmail: emailDomain,
},
},

View File

@ -1,9 +1,11 @@
import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@ -20,11 +22,13 @@ type UpdateOptions = {
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id;
const currentOrgId = ctx.user?.organization?.id || input.orgId;
if (!currentOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!currentOrgId || ctx.user.role !== UserPermissionRole.ADMIN)
throw new TRPCError({ code: "UNAUTHORIZED" });
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId)) || ctx.user.role !== UserPermissionRole.ADMIN)
throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) {
const userConflict = await prisma.team.findMany({
@ -51,6 +55,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 data: Prisma.TeamUpdateArgs["data"] = {
name: input.name,
@ -64,6 +69,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
timeZone: input.timeZone,
weekStart: input.weekStart,
timeFormat: input.timeFormat,
metadata: mergeMetadata({ ...input.metadata }),
};
if (input.slug) {
@ -73,20 +79,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
!prevOrganisation.slug
) {
// Save it on the metadata so we can use it later
data.metadata = {
requestedSlug: input.slug,
};
data.metadata = mergeMetadata({ requestedSlug: input.slug });
} else {
data.slug = input.slug;
data.metadata = mergeMetadata({
// If we save slug, we don't need the requestedSlug anymore
const metadataParse = teamMetadataSchema.safeParse(prevOrganisation.metadata);
if (metadataParse.success) {
const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {};
data.metadata = {
...cleanMetadata,
};
}
requestedSlug: undefined,
...input.metadata,
});
}
}

View File

@ -1,11 +1,14 @@
import { z } from "zod";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const ZUpdateInputSchema = z.object({
name: z.string().optional(),
orgId: z
.string()
.regex(/^\d+$/)
.transform((id) => parseInt(id))
.or(z.number())
.optional(),
bio: z.string().optional(),
logo: z
@ -22,6 +25,7 @@ export const ZUpdateInputSchema = z.object({
timeZone: z.string().optional(),
weekStart: z.string().optional(),
timeFormat: z.number().optional(),
metadata: teamMetadataSchema.unwrap().pick({ isOrganizationConfigured: true }).optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;