Compare commits

...
15 Commits
Author SHA1 Message Date
Keith Williams b4b38b9bc3 v3.2.3
release.yml / release (push) Canceled after 0s
2023-08-24 17:16:02 +03:00
Crowdin Bot d32719ad8b New Crowdin translations by Github Action 2023-08-24 12:47:12 +00:00
Hariom Balhara 99d0816d9f phoneSchema missing async parsing (#10929) 2023-08-24 12:43:52 +00:00
95f4b89fe8 fix: #10783 (UI updated on API Creation Form ) (#10786)
* Fix #10783

* Update the changes #10783

* Updated the changes on the another dialog footer

* Updated the changes

---------

Co-authored-by: Hem Pun <hemthegentlmen@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-08-24 11:20:48 +01:00
Abhijeet SinghandAbhijeet Singh 3e63c2b717 fix: Italics not being shown in the description after saving (#10848)
* added ignore rule for em tag in turndown

* added ignore rule for em tag in turndown

---------

Co-authored-by: Abhijeet Singh <asing9829@gmail.com>
2023-08-24 11:20:00 +01:00
Omar López 42cd56578d fix: Reduced boilerplate for publicViewer router (#10849) 2023-08-24 11:19:24 +01:00
Ujwal KumarandBailey Pumfleet 31d4cb4eb7 Transform # character to - in slug (#10896)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-08-24 11:19:10 +01:00
sean-brydonandBailey Pumfleet fde3e8bea9 Invalidate and add success message (#10907)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-08-24 11:18:59 +01:00
GitStart-Cal.comandgitstart-calcom 874ad29676 chore: add Table in storybook (CALCOM-10760) (#10869)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-24 11:07:57 +01:00
Hariom Balhara 5503d9d432 perf: bookings query improvement (#10687) 2023-08-24 11:14:10 +02:00
Keith WilliamsandShivam Kalra 43b3d68447 chore: Don't render certain components when running meticulous tests (#10772)
* chore: Don't render credits when running meticulous tests

* fix: type window Meticulous

* fix: window reference error

* chore: window interface

* chore: disable tzdialog popup on meticulous

* chore: IS_VISUAL_REGRESSION_TESTING

* fix: constants

* fix: only run on preview builds

---------

Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-08-24 13:42:53 +05:30
GitStart-Cal.comandgitstart-calcom e3c747a867 test: Create unit tests for react components is packages/ui/components/navigation (#10698)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-24 13:23:54 +05:30
sean-brydonandOmar López e020a2c9a4 feat: Availability Slider enabled for teams (#10902)
Co-authored-by: Omar López <zomars@me.com>
2023-08-23 21:23:24 +00:00
Leo Giovanetti e6e6f09547 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
2023-08-23 21:01:12 +00:00
Leo GiovanettiandJoe Au-Yeung 68ec946c59 fix: add org avatar to org booking pages [CAL-2333] (#10847)
* Add org avatar to org booking pages

* Using web app url for org avatars + missing i18n string

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-08-23 20:10:02 +00:00
101 changed files with 1870 additions and 668 deletions
@@ -35,6 +35,7 @@ import {
Tooltip,
} from "@calcom/ui";
import { Copy, Edit } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -286,36 +287,38 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}}>
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
<div className="lg:-ml-2">
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
labelSrOnly
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}>
<Copy className="h-4 w-4" />
</Button>
</Tooltip>
}
/>
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
labelSrOnly
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}>
<Copy className="h-4 w-4" />
</Button>
</Tooltip>
}
/>
)}
</div>
</SettingsToggle>
<hr className="border-subtle" />
+1
View File
@@ -0,0 +1 @@
export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulous?.isRunningAsTest);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.2",
"version": "3.2.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1 -1
View File
@@ -51,7 +51,7 @@ class MyDocument extends Document<Props> {
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
{(!IS_PRODUCTION || process.env.VERCEL_ENV === "preview") && (
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
<script
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
+13 -16
View File
@@ -2,7 +2,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback } from "react";
import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell";
@@ -135,7 +134,6 @@ export default function AvailabilityPage() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const orgBranding = useOrgBranding();
// Get a new searchParams string by merging the current
// searchParams with a provided key/value pair
@@ -156,23 +154,22 @@ export default function AvailabilityPage() {
subtitle={t("configure_availability")}
CTA={
<div className="flex gap-2">
{orgBranding && (
<ToggleGroup
className="hidden md:block"
defaultValue={searchParams?.get("type") ?? "mine"}
onValueChange={(value) => {
router.push(`${pathname}?${createQueryString("type", value)}`);
}}
options={[
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
]}
/>
)}
<ToggleGroup
className="hidden md:block"
defaultValue={searchParams?.get("type") ?? "mine"}
onValueChange={(value) => {
if (!value) return;
router.push(`${pathname}?${createQueryString("type", value)}`);
}}
options={[
{ value: "mine", label: t("my_availability") },
{ value: "team", label: t("team_availability") },
]}
/>
<NewScheduleButton />
</div>
}>
{searchParams?.get("type") === "team" && orgBranding ? (
{searchParams?.get("type") === "team" ? (
<AvailabilitySliderTable />
) : (
<WithQuery
@@ -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;
@@ -2010,6 +2010,21 @@
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -2006,5 +2006,17 @@
"member_removed": "Membre supprimé",
"my_availability": "Mes disponibilités",
"team_availability": "Disponibilités de l'équipe",
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
"unverified": "Non vérifié",
"dns_missing": "DNS manquant",
"mark_dns_configured": "Marquer comme DNS configuré",
"value": "Valeur",
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -1,8 +1,8 @@
import type { PrismaClient } from "@prisma/client";
import type { App_RoutingForms_Form } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getSerializableForm } from "../lib/getSerializableForm";
@@ -1,7 +1,8 @@
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
import logger from "@calcom/lib/logger";
import type { PrismaClient, Prisma } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { entries } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
@@ -1,7 +1,7 @@
import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm";
+6
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));
};
@@ -5,7 +5,6 @@ import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CallToActionIcon } from "./CallToActionIcon";
import { Info } from "./Info";
export function LocationInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
@@ -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>
);
};
+1
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";
@@ -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();
}
}
@@ -63,7 +63,12 @@ export const EventMeta = () => {
)}
{!isLoading && !!event && (
<m.div {...fadeInUp} layout transition={{ ...fadeInUp.transition, delay: 0.3 }}>
<EventMembers schedulingType={event.schedulingType} users={event.users} profile={event.profile} />
<EventMembers
schedulingType={event.schedulingType}
users={event.users}
profile={event.profile}
entity={event.entity}
/>
<EventTitle className="my-2">{event?.title}</EventTitle>
{event.description && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
@@ -1,4 +1,5 @@
import { CAL_URL } from "@calcom/lib/constants";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { SchedulingType } from "@calcom/prisma/enums";
import { AvatarGroup } from "@calcom/ui";
@@ -12,6 +13,7 @@ export interface EventMembersProps {
schedulingType: PublicEvent["schedulingType"];
users: PublicEvent["users"];
profile: PublicEvent["profile"];
entity: PublicEvent["entity"];
}
type Avatar = {
@@ -23,7 +25,7 @@ type Avatar = {
type AvatarWithRequiredImage = Avatar & { image: string };
export const EventMembers = ({ schedulingType, users, profile }: EventMembersProps) => {
export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => {
const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN;
const shownUsers = showMembers ? users : [];
@@ -37,9 +39,19 @@ export const EventMembers = ({ schedulingType, users, profile }: EventMembersPro
title: `${user.name}`,
image: "image" in user ? `${user.image}` : `/${user.username}/avatar.png`,
alt: user.name || undefined,
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
href: `/${user.username}`,
}));
// Add organization avatar
if (entity.orgSlug) {
avatars.unshift({
title: `${entity.name}`,
image: `${WEBAPP_URL}/team/${entity.orgSlug}/avatar.png`,
alt: entity.name || undefined,
href: getOrgFullDomain(entity.orgSlug),
});
}
// Add profile later since we don't want to force creating an avatar for this if it doesn't exist.
avatars.unshift({
title: `${profile.name}`,
@@ -1,8 +1,9 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { z } from "zod";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
type BookingSelect = {
@@ -223,7 +223,7 @@ function preprocess<T extends z.ZodType>({
if (optionValue) {
// `typeOfOptionInput` can be any of the main types. So, we the same validations should run for `optionValue`
if (typeOfOptionInput === "phone") {
if (!phoneSchema.safeParse(optionValue).success) {
if (!(await phoneSchema.safeParseAsync(optionValue)).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") });
}
}
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import type { Prisma, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import type { EventManagerUser } from "@calcom/core/EventManager";
@@ -11,6 +11,7 @@ import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
@@ -107,7 +107,7 @@ export default function ApiKeyDialogForm({
: `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`}
</span>
</div>
<DialogFooter showDivider className="!mt-12">
<DialogFooter showDivider className="relative">
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
{t("done")}
</Button>
@@ -209,7 +209,7 @@ export default function ApiKeyDialogForm({
</div>
)}
<DialogFooter showDivider className="!mt-12">
<DialogFooter showDivider className="relative">
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
@@ -1,8 +1,8 @@
import type { PrismaClient } from "@prisma/client";
import cache from "memory-cache";
import { z } from "zod";
import { CONSOLE_URL } from "@calcom/lib/constants";
import type { PrismaClient } from "@calcom/prisma";
const CACHING_TIME = 86400000; // 24 hours in milliseconds
@@ -1,4 +1,4 @@
import type { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@calcom/prisma";
export async function getDeploymentKey(prisma: PrismaClient) {
const deployment = await prisma.deployment.findUnique({
@@ -1,9 +1,10 @@
import type { PrismaClient, Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { sendSlugReplacementEmail } from "@calcom/emails/email-manager";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { _EventTypeModel } from "@calcom/prisma/zod";
import { allManagedEventTypeProps, unlockedManagedEventTypeProps } from "@calcom/prisma/zod-utils";
@@ -48,7 +48,7 @@ export function subdomainSuffix() {
}
export function getOrgFullDomain(slug: string, options: { protocol: boolean } = { protocol: true }) {
return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}/`;
return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`;
}
export function getSlugOrRequestedSlug(slug: string) {
@@ -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;
@@ -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;
@@ -57,7 +57,7 @@ const OrgProfileView = () => {
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
showToast(t("your_organisation_updated_sucessfully"), "success");
showToast(t("your_organization_updated_sucessfully"), "success");
},
});
+1 -1
View File
@@ -1,8 +1,8 @@
import type { SAMLSSORecord, OIDCSSORecord } from "@boxyhq/saml-jackson";
import type { PrismaClient } from "@prisma/client";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
@@ -35,6 +35,7 @@ export default function TeamInviteList(props: Props) {
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
},
async onError(err) {
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
showToast,
} from "@calcom/ui";
import { Ban, Check, MoreHorizontal, X } from "@calcom/ui/components/icon";
@@ -39,7 +40,9 @@ export default function TeamInviteListItem(props: Props) {
const acceptOrLeaveMutation = trpc.viewer.teams.acceptOrLeave.useMutation({
onSuccess: async () => {
showToast(t("success"), "success");
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.hasTeamPlan.invalidate();
await utils.viewer.teams.list.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
},
@@ -67,7 +67,10 @@ export default function TeamListItem(props: Props) {
const acceptOrLeaveMutation = trpc.viewer.teams.acceptOrLeave.useMutation({
onSuccess: () => {
showToast(t("success"), "success");
utils.viewer.teams.get.invalidate();
utils.viewer.teams.list.invalidate();
utils.viewer.teams.hasTeamPlan.invalidate();
utils.viewer.teams.listInvites.invalidate();
},
});
@@ -9,7 +9,7 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils";
import {
bookerLayoutOptions,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { AppFlags } from "../config";
@@ -5,6 +5,7 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
export default function TimezoneChangeDialog() {
const { t } = useLocale();
@@ -43,7 +44,7 @@ export default function TimezoneChangeDialog() {
const tzDifferent =
!isLoading && dayjs.tz(undefined, currentTz).utcOffset() !== dayjs.tz(undefined, userTz).utcOffset();
const showDialog = tzDifferent && !document.cookie.includes("calcom-timezone-dialog=1");
setOpen(showDialog);
setOpen(!IS_VISUAL_REGRESSION_TESTING && showDialog);
}, [currentTz, isLoading, userTz]);
// save cookie to not show again
+2 -1
View File
@@ -77,6 +77,7 @@ import {
Zap,
} from "@calcom/ui/components/icon";
import { Discord } from "@calcom/ui/components/icon/Discord";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import { useOrgBranding } from "../ee/organizations/context/provider";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
@@ -920,7 +921,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
</ButtonOrLink>
</Tooltip>
))}
<Credits />
{!IS_VISUAL_REGRESSION_TESTING && <Credits />}
</div>
</aside>
</div>
@@ -3,11 +3,14 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useMemo, useRef, useCallback, useEffect, useState } from "react";
import dayjs from "@calcom/dayjs";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import type { DateRange } from "@calcom/lib/date-ranges";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { Avatar, Button, ButtonGroup, DataTable } from "@calcom/ui";
import { UpgradeTip } from "../../tips/UpgradeTip";
import { TBContext, createTimezoneBuddyStore } from "../store";
import { TimeDial } from "./TimeDial";
@@ -20,6 +23,32 @@ export interface SliderUser {
dateRanges: DateRange[];
}
function UpgradeTeamTip() {
const { t } = useLocale();
return (
<UpgradeTip
title={t("calcom_is_better_with_team", { appName: APP_NAME }) as string}
description="add_your_team_members"
background="/tips/teams"
features={[]}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<></>
</UpgradeTip>
);
}
export function AvailabilitySliderTable() {
const tableContainerRef = useRef<HTMLDivElement>(null);
const [browsingDate, setBrowsingDate] = useState(dayjs());
@@ -143,6 +172,9 @@ export function AvailabilitySliderTable() {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
// This means they are not apart of any teams so we show the upgrade tip
if (!flatData.length) return <UpgradeTeamTip />;
return (
<TBContext.Provider
value={createTimezoneBuddyStore({
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import defaultPrisma from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
export type GetSubscriberOptions = {
+2 -1
View File
@@ -1,8 +1,9 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { z } from "zod";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import type { PrismaClient } from "@calcom/prisma";
type BookingSelect = {
description: true;
+1 -1
View File
@@ -1,4 +1,3 @@
import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { getLocationGroupedOptions } from "@calcom/app-store/server";
@@ -10,6 +9,7 @@ import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@cal
import { CAL_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType, MembershipRole, AppCategories } from "@calcom/prisma/enums";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
+32
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>;
},
};
}
@@ -1,4 +1,4 @@
import type { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@calcom/prisma";
export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) {
// Look bookingUid in bookingSeat
+3 -1
View File
@@ -8,10 +8,12 @@ describe("slugify", () => {
expect(slugify("HELLO")).toEqual("hello");
});
it("should convert spaces, _, and any other special character to -", () => {
it("should convert spaces, _, +, # and any other special character to -", () => {
expect(slugify("hello there")).toEqual("hello-there");
expect(slugify("hello_there")).toEqual("hello-there");
expect(slugify("hello$there")).toEqual("hello-there");
expect(slugify("hello+there")).toEqual("hello-there");
expect(slugify("#hellothere")).toEqual("hellothere");
});
it("should keep numbers as is", () => {
+2 -3
View File
@@ -5,10 +5,9 @@ export const slugify = (str: string) => {
.normalize("NFD") // Normalize to decomposed form for handling accents
.replace(/\p{Diacritic}/gu, "") // Remove any diacritics (accents) from characters
.replace(/[^\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode) with a dash
.replace(/[\s_]+/g, "-") // Replace whitespace and underscores with a single dash
.replace(/[\s_#]+/g, "-") // Replace whitespace, # and underscores with a single dash
.replace(/^-+/, "") // Remove dashes from start
.replace(/-+$/, "") // Remove dashes from end
.replace(/\+/g, "-"); // Transform all + to -
.replace(/-+$/, ""); // Remove dashes from end
};
export default slugify;
+7
View File
@@ -31,6 +31,13 @@ turndownService.addRule("enter", {
},
});
turndownService.addRule("ignoreEmphasized", {
filter: "em",
replacement: function (content) {
return content;
},
});
function isShiftEnter(node: HTMLElement) {
let currentNode: HTMLElement | null | ParentNode = node;
+43 -6
View File
@@ -1,28 +1,65 @@
import type { Prisma } from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client";
import { bookingReferenceMiddleware } from "./middleware";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
var prisma: typeof prismaWithClientExtensions;
}
const prismaOptions: Prisma.PrismaClientOptions = {};
if (!!process.env.NEXT_PUBLIC_DEBUG) prismaOptions.log = ["query", "error", "warn"];
export const prisma = globalThis.prisma || new PrismaClient(prismaOptions);
const prismaWithoutClientExtensions = new PrismaClientWithoutExtension(prismaOptions);
export const customPrisma = (options: Prisma.PrismaClientOptions) =>
new PrismaClient({ ...prismaOptions, ...options });
new PrismaClientWithoutExtension({ ...prismaOptions, ...options });
// If any changed on middleware server restart is required
// TODO: Migrate it to $extends
bookingReferenceMiddleware(prismaWithoutClientExtensions);
// FIXME: Due to some reason, there are types failing in certain places due to the $extends. Fix it and then enable it
// Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'`
// const prismaWithClientExtensions = prismaWithoutClientExtensions.$extends({
// query: {
// $allModels: {
// async $allOperations({ model, operation, args, query }) {
// const start = performance.now();
// /* your custom logic here */
// const res = await query(args);
// const end = performance.now();
// logger.debug("Query Perf: ", `${model}.${operation} took ${(end - start).toFixed(2)}ms\n`);
// return res;
// },
// },
// },
// });
// .$extends({
// name: "teamUpdateWithMetadata",
// query: {
// team: {
// async update({ model, operation, args, query }) {
// if (args.data.metadata) {
// // Prepare args.data with merged metadata
// }
// return query(args);
// },
// },
// },
// })
const prismaWithClientExtensions = prismaWithoutClientExtensions;
export const prisma = (globalThis.prisma as typeof prismaWithClientExtensions) || prismaWithClientExtensions;
if (process.env.NODE_ENV !== "production") {
globalThis.prisma = prisma;
}
// If any changed on middleware server restart is required
bookingReferenceMiddleware(prisma);
export type PrismaClient = typeof prisma;
export default prisma;
export * from "./selects";
@@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
function middleware(prisma: PrismaClient) {
/***********************************/
+325
View File
@@ -0,0 +1,325 @@
/**
* This script can be used to seed the database with a lot of data for performance testing.
* TODO: Make it more structured and configurable from CLI
* Run it as `npx ts-node --transpile-only ./seed-performance-testing.ts`
*/
import { uuid } from "short-uuid";
import dailyMeta from "@calcom/app-store/dailyvideo/_metadata";
import googleMeetMeta from "@calcom/app-store/googlevideo/_metadata";
import zoomMeta from "@calcom/app-store/zoomvideo/_metadata";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { createUserAndEventType } from "./seed-utils";
async function createManyDifferentUsersWithDifferentEventTypesAndBookings({
tillUser,
startFrom = 0,
}: {
tillUser: number;
startFrom?: number;
}) {
for (let i = startFrom; i < tillUser; i++) {
await createUserAndEventType({
user: {
email: `pro${i}@example.com`,
name: "Pro Example",
password: "1111",
username: `pro${i}`,
theme: "light",
},
eventTypes: [
{
title: "30min",
slug: "30min",
length: 30,
_bookings: [
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
},
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(30, "minutes").toDate(),
status: BookingStatus.PENDING,
},
],
},
{
title: "60min",
slug: "60min",
length: 60,
},
{
title: "Multiple duration",
slug: "multiple-duration",
length: 75,
metadata: {
multipleDuration: [30, 75, 90],
},
},
{
title: "paid",
slug: "paid",
length: 60,
price: 100,
},
{
title: "In person meeting",
slug: "in-person",
length: 60,
locations: [{ type: "inPerson", address: "London" }],
},
{
title: "Zoom Event",
slug: "zoom",
length: 60,
locations: [{ type: zoomMeta.appData?.location?.type }],
},
{
title: "Daily Event",
slug: "daily",
length: 60,
locations: [{ type: dailyMeta.appData?.location?.type }],
},
{
title: "Google Meet",
slug: "google-meet",
length: 60,
locations: [{ type: googleMeetMeta.appData?.location?.type }],
},
{
title: "Yoga class",
slug: "yoga-class",
length: 30,
recurringEvent: { freq: 2, count: 12, interval: 1 },
_bookings: [
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(1, "week").toDate(),
endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(2, "week").toDate(),
endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(3, "week").toDate(),
endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(4, "week").toDate(),
endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Yoga class",
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(5, "week").toDate(),
endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Seeded Yoga class",
description: "seeded",
recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"),
startTime: dayjs().subtract(4, "day").toDate(),
endTime: dayjs().subtract(4, "day").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Seeded Yoga class",
description: "seeded",
recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"),
startTime: dayjs().subtract(4, "day").add(1, "week").toDate(),
endTime: dayjs().subtract(4, "day").add(1, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Seeded Yoga class",
description: "seeded",
recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"),
startTime: dayjs().subtract(4, "day").add(2, "week").toDate(),
endTime: dayjs().subtract(4, "day").add(2, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
{
uid: uuid(),
title: "Seeded Yoga class",
description: "seeded",
recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"),
startTime: dayjs().subtract(4, "day").add(3, "week").toDate(),
endTime: dayjs().subtract(4, "day").add(3, "week").add(30, "minutes").toDate(),
status: BookingStatus.ACCEPTED,
},
],
},
{
title: "Tennis class",
slug: "tennis-class",
length: 60,
recurringEvent: { freq: 2, count: 10, interval: 2 },
requiresConfirmation: true,
_bookings: [
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(60, "minutes").toDate(),
status: BookingStatus.PENDING,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(2, "week").toDate(),
endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(),
status: BookingStatus.PENDING,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(4, "week").toDate(),
endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(),
status: BookingStatus.PENDING,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(8, "week").toDate(),
endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(),
status: BookingStatus.PENDING,
},
{
uid: uuid(),
title: "Tennis class",
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(10, "week").toDate(),
endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(),
status: BookingStatus.PENDING,
},
],
},
],
});
}
}
async function createAUserWithManyBookings() {
const random = Math.random();
await createUserAndEventType({
user: {
email: `pro-${random}@example.com`,
name: "Pro Example",
password: "1111",
username: `pro-${random}`,
theme: "light",
},
eventTypes: [
{
title: "30min",
slug: "30min",
length: 30,
_numBookings: 100,
},
{
title: "60min",
slug: "60min",
length: 60,
_numBookings: 100,
},
{
title: "Multiple duration",
slug: "multiple-duration",
length: 75,
metadata: {
multipleDuration: [30, 75, 90],
},
_numBookings: 100,
},
{
title: "paid",
slug: "paid",
length: 60,
price: 100,
_numBookings: 100,
},
{
title: "Zoom Event",
slug: "zoom",
length: 60,
locations: [{ type: zoomMeta.appData?.location?.type }],
_numBookings: 100,
},
{
title: "Daily Event",
slug: "daily",
length: 60,
locations: [{ type: dailyMeta.appData?.location?.type }],
_numBookings: 100,
},
{
title: "Google Meet",
slug: "google-meet",
length: 60,
locations: [{ type: googleMeetMeta.appData?.location?.type }],
_numBookings: 100,
},
{
title: "Yoga class",
slug: "yoga-class",
length: 30,
_numBookings: 100,
},
{
title: "Tennis class",
slug: "tennis-class",
length: 60,
recurringEvent: { freq: 2, count: 10, interval: 2 },
requiresConfirmation: true,
_numBookings: 100,
},
],
});
}
// createManyDifferentUsersWithDifferentEventTypesAndBookings({
// tillUser: 20000,
// startFrom: 10000,
// });
createAUserWithManyBookings();
+147
View File
@@ -0,0 +1,147 @@
import type { Prisma, UserPermissionRole } from "@prisma/client";
import { uuid } from "short-uuid";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from ".";
export async function createUserAndEventType({
user,
eventTypes = [],
}: {
user: {
email: string;
password: string;
username: string;
name: string;
completedOnboarding?: boolean;
timeZone?: string;
role?: UserPermissionRole;
theme?: "dark" | "light";
};
eventTypes?: Array<
Prisma.EventTypeUncheckedCreateInput & {
_bookings?: Prisma.BookingCreateInput[];
_numBookings?: number;
}
>;
}) {
const userData = {
...user,
password: await hashPassword(user.password),
emailVerified: new Date(),
completedOnboarding: user.completedOnboarding ?? true,
locale: "en",
schedules:
user.completedOnboarding ?? true
? {
create: {
name: "Working Hours",
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
},
},
},
}
: undefined,
};
const theUser = await prisma.user.upsert({
where: { email_username: { email: user.email, username: user.username } },
update: userData,
create: userData,
});
console.log(
`👤 Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page 👉 ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}`
);
for (const eventTypeInput of eventTypes) {
const { _bookings, _numBookings, ...eventTypeData } = eventTypeInput;
let bookingFields;
if (_bookings && _numBookings) {
throw new Error("You can't set both _bookings and _numBookings");
} else if (_numBookings) {
bookingFields = [...Array(_numBookings).keys()].map((i) => ({
startTime: dayjs()
.add(1, "day")
.add(i * 5 + 0, "minutes")
.toDate(),
endTime: dayjs()
.add(1, "day")
.add(i * 5 + 30, "minutes")
.toDate(),
title: `${eventTypeInput.title}:${i + 1}`,
uid: uuid(),
}));
} else {
bookingFields = _bookings || [];
}
eventTypeData.userId = theUser.id;
eventTypeData.users = { connect: { id: theUser.id } };
const eventType = await prisma.eventType.findFirst({
where: {
slug: eventTypeData.slug,
users: {
some: {
id: eventTypeData.userId,
},
},
},
select: {
id: true,
},
});
if (eventType) {
console.log(
`\t📆 Event type ${eventTypeData.slug} already seems seeded - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
);
continue;
}
const { id } = await prisma.eventType.create({
data: eventTypeData,
});
console.log(
`\t📆 Event type ${eventTypeData.slug} with id ${id}, length ${eventTypeData.length}min - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
);
for (const bookingInput of bookingFields) {
await prisma.booking.create({
data: {
...bookingInput,
user: {
connect: {
email: user.email,
},
},
attendees: {
create: {
email: user.email,
name: user.name,
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id,
},
},
status: bookingInput.status,
},
});
console.log(
`\t\t☎️ Created booking ${bookingInput.title} at ${new Date(
bookingInput.startTime
).toLocaleDateString()}`
);
}
}
console.log("👤 User with it's event-types and bookings created", theUser.email);
return theUser;
}
+2 -121
View File
@@ -1,4 +1,4 @@
import type { Prisma, UserPermissionRole } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { uuid } from "short-uuid";
import type z from "zod";
@@ -6,132 +6,13 @@ import dailyMeta from "@calcom/app-store/dailyvideo/_metadata";
import googleMeetMeta from "@calcom/app-store/googlevideo/_metadata";
import zoomMeta from "@calcom/app-store/zoomvideo/_metadata";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import prisma from ".";
import mainAppStore from "./seed-app-store";
import { createUserAndEventType } from "./seed-utils";
import type { teamMetadataSchema } from "./zod-utils";
async function createUserAndEventType({
user,
eventTypes = [],
}: {
user: {
email: string;
password: string;
username: string;
name: string;
completedOnboarding?: boolean;
timeZone?: string;
role?: UserPermissionRole;
theme?: "dark" | "light";
};
eventTypes?: Array<
Prisma.EventTypeUncheckedCreateInput & {
_bookings?: Prisma.BookingCreateInput[];
}
>;
}) {
const userData = {
...user,
password: await hashPassword(user.password),
emailVerified: new Date(),
completedOnboarding: user.completedOnboarding ?? true,
locale: "en",
schedules:
user.completedOnboarding ?? true
? {
create: {
name: "Working Hours",
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
},
},
},
}
: undefined,
};
const theUser = await prisma.user.upsert({
where: { email_username: { email: user.email, username: user.username } },
update: userData,
create: userData,
});
console.log(
`👤 Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page 👉 ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}`
);
for (const eventTypeInput of eventTypes) {
const { _bookings: bookingFields = [], ...eventTypeData } = eventTypeInput;
eventTypeData.userId = theUser.id;
eventTypeData.users = { connect: { id: theUser.id } };
const eventType = await prisma.eventType.findFirst({
where: {
slug: eventTypeData.slug,
users: {
some: {
id: eventTypeData.userId,
},
},
},
select: {
id: true,
},
});
if (eventType) {
console.log(
`\t📆 Event type ${eventTypeData.slug} already seems seeded - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
);
continue;
}
const { id } = await prisma.eventType.create({
data: eventTypeData,
});
console.log(
`\t📆 Event type ${eventTypeData.slug} with id ${id}, length ${eventTypeData.length}min - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
);
for (const bookingInput of bookingFields) {
await prisma.booking.create({
data: {
...bookingInput,
user: {
connect: {
email: user.email,
},
},
attendees: {
create: {
email: user.email,
name: user.name,
timeZone: "Europe/London",
},
},
eventType: {
connect: {
id,
},
},
status: bookingInput.status,
},
});
console.log(
`\t\t☎️ Created booking ${bookingInput.title} at ${new Date(
bookingInput.startTime
).toLocaleDateString()}`
);
}
}
return theUser;
}
async function createTeamAndAddUsers(
teamInput: Prisma.TeamCreateInput,
users: { id: number; username: string; role?: MembershipRole }[] = []
+1
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()
@@ -1,6 +1,7 @@
import type { Session } from "next-auth";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
@@ -131,17 +132,25 @@ const getUserSession = async (ctx: TRPCContextInner) => {
return { user, session };
};
const sessionMiddleware = middleware(async ({ ctx, next }) => {
const { user, session } = await getUserSession(ctx);
const sessionMiddleware = middleware(async ({ ctx, next }) => {
const middlewareStart = performance.now();
const { user, session } = await getUserSession(ctx);
const middlewareEnd = performance.now();
logger.debug("Perf:t.sessionMiddleware", middlewareEnd - middlewareStart);
return next({
ctx: { user, session },
});
});
export const isAuthed = middleware(async ({ ctx, next }) => {
const middlewareStart = performance.now();
const { user, session } = await getUserSession(ctx);
const middlewareEnd = performance.now();
logger.debug("Perf:t.isAuthed", middlewareEnd - middlewareStart);
if (!user || !session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
@@ -1,7 +1,6 @@
import { type PrismaClient } from "@prisma/client";
import { isOrganization, withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils";
import { getBookerUrl } from "@calcom/lib/server/getBookerUrl";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@@ -1,136 +1,52 @@
import sessionMiddleware from "../../middlewares/sessionMiddleware";
import publicProcedure from "../../procedures/publicProcedure";
import { router } from "../../trpc";
import { importHandler, router } from "../../trpc";
import { slotsRouter } from "../viewer/slots/_router";
import { ZEventInputSchema } from "./event.schema";
import { i18nInputSchema } from "./i18n.schema";
import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
type PublicViewerRouterHandlerCache = {
session?: typeof import("./session.handler").sessionHandler;
i18n?: typeof import("./i18n.handler").i18nHandler;
countryCode?: typeof import("./countryCode.handler").countryCodeHandler;
samlTenantProduct?: typeof import("./samlTenantProduct.handler").samlTenantProductHandler;
stripeCheckoutSession?: typeof import("./stripeCheckoutSession.handler").stripeCheckoutSessionHandler;
cityTimezones?: typeof import("./cityTimezones.handler").cityTimezonesHandler;
event?: typeof import("./event.handler").eventHandler;
};
const NAMESPACE = "publicViewer";
const UNSTABLE_HANDLER_CACHE: PublicViewerRouterHandlerCache = {};
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
// things that unauthenticated users can query about themselves
export const publicViewerRouter = router({
session: publicProcedure.use(sessionMiddleware).query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.session) {
UNSTABLE_HANDLER_CACHE.session = await import("./session.handler").then((mod) => mod.sessionHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.session) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.session({
ctx,
});
session: publicProcedure.use(sessionMiddleware).query(async (opts) => {
const handler = await importHandler(namespaced("session"), () => import("./session.handler"));
return handler(opts);
}),
i18n: publicProcedure.input(i18nInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.i18n) {
UNSTABLE_HANDLER_CACHE.i18n = await import("./i18n.handler").then((mod) => mod.i18nHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.i18n) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.i18n({ ctx, input });
i18n: publicProcedure.input(i18nInputSchema).query(async (opts) => {
const handler = await importHandler(namespaced("i18n"), () => import("./i18n.handler"));
return handler(opts);
}),
countryCode: publicProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.countryCode) {
UNSTABLE_HANDLER_CACHE.countryCode = await import("./countryCode.handler").then(
(mod) => mod.countryCodeHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.countryCode) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.countryCode({
ctx,
});
countryCode: publicProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("countryCode"), () => import("./countryCode.handler"));
return handler(opts);
}),
samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) {
UNSTABLE_HANDLER_CACHE.samlTenantProduct = await import("./samlTenantProduct.handler").then(
(mod) => mod.samlTenantProductHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.samlTenantProduct) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.samlTenantProduct({
ctx,
input,
});
samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async (opts) => {
const handler = await importHandler(
namespaced("samlTenantProduct"),
() => import("./samlTenantProduct.handler")
);
return handler(opts);
}),
stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async ({ input }) => {
if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) {
UNSTABLE_HANDLER_CACHE.stripeCheckoutSession = await import("./stripeCheckoutSession.handler").then(
(mod) => mod.stripeCheckoutSessionHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.stripeCheckoutSession) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.stripeCheckoutSession({
input,
});
stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async (opts) => {
const handler = await importHandler(
namespaced("stripeCheckoutSession"),
() => import("./stripeCheckoutSession.handler")
);
return handler(opts);
}),
cityTimezones: publicProcedure.query(async () => {
if (!UNSTABLE_HANDLER_CACHE.cityTimezones) {
UNSTABLE_HANDLER_CACHE.cityTimezones = await import("./cityTimezones.handler").then(
(mod) => mod.cityTimezonesHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.cityTimezones) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.cityTimezones();
const handler = await importHandler(namespaced("cityTimezones"), () => import("./cityTimezones.handler"));
return handler();
}),
// REVIEW: This router is part of both the public and private viewer router?
slots: slotsRouter,
event: publicProcedure.input(ZEventInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.event) {
UNSTABLE_HANDLER_CACHE.event = await import("./event.handler").then((mod) => mod.eventHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.event) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.event({
ctx,
input,
});
event: publicProcedure.input(ZEventInputSchema).query(async (opts) => {
const handler = await importHandler(namespaced("event"), () => import("./event.handler"));
return handler(opts);
}),
});
@@ -28,3 +28,5 @@ export const cityTimezonesHandler = async () => {
return uniqueCities;
};
export default cityTimezonesHandler;
@@ -10,3 +10,5 @@ export const countryCodeHandler = async ({ ctx }: CountryCodeOptions) => {
const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? "";
return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode };
};
export default countryCodeHandler;
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import type { PrismaClient } from "@calcom/prisma";
import type { TEventInputSchema } from "./event.schema";
@@ -19,3 +18,5 @@ export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => {
);
return event;
};
export default eventHandler;
@@ -21,3 +21,5 @@ export const i18nHandler = async ({ input }: I18nOptions) => {
locale,
};
};
export default i18nHandler;
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import type { PrismaClient } from "@calcom/prisma";
import type { TSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
@@ -17,3 +16,5 @@ export const samlTenantProductHandler = ({ ctx, input }: SamlTenantProductOption
return samlTenantProduct(prisma, email);
};
export default samlTenantProductHandler;
@@ -9,3 +9,5 @@ type SessionOptions = {
export const sessionHandler = async ({ ctx }: SessionOptions) => {
return ctx.session;
};
export default sessionHandler;
@@ -61,3 +61,5 @@ export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSess
};
}
};
export default stripeCheckoutSessionHandler;
@@ -1,7 +1,8 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import { getLocalAppMetadata } from "@calcom/app-store/utils";
import type { PrismaClient } from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../trpc";
@@ -1,8 +1,8 @@
import type { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import { getLocalAppMetadata } from "@calcom/app-store/utils";
import type { PrismaClient } from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
// import prisma from "@calcom/prisma";
@@ -1,8 +1,7 @@
import type { PrismaClient } from "@prisma/client";
import { getLocalAppMetadata } from "@calcom/app-store/utils";
import { sendDisabledAppEmail } from "@calcom/emails";
import { getTranslation } from "@calcom/lib/server";
import type { PrismaClient } from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -1,3 +1,6 @@
import { Prisma } from "@prisma/client";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/lib/date-ranges";
import { buildDateRanges } from "@calcom/lib/date-ranges";
@@ -15,25 +18,32 @@ type GetOptions = {
input: TListTeamAvailaiblityScheme;
};
export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) => {
const organizationId = ctx.user.organizationId;
async function getTeamMembers({
teamId,
teamIds,
cursor,
limit,
}: {
teamId?: number;
teamIds?: number[];
cursor: number | null | undefined;
limit: number;
}) {
let whereQuery: Prisma.MembershipWhereInput = {
teamId,
};
if (!organizationId) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
if (teamIds) {
whereQuery = {
teamId: {
in: teamIds,
},
};
}
const { cursor, limit } = input;
const getTotalMembers = await prisma.membership.count({
return await prisma.membership.findMany({
where: {
teamId: organizationId,
},
});
// I couldnt get this query to work direct on membership table
const teamMembers = await prisma.membership.findMany({
where: {
teamId: organizationId,
...whereQuery,
accepted: true,
},
select: {
@@ -54,7 +64,128 @@ export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) =>
orderBy: {
id: "asc",
},
distinct: ["userId"],
});
}
type Member = Awaited<ReturnType<typeof getTeamMembers>>[number];
async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) {
if (!member.user.defaultScheduleId) {
return {
id: member.user.id,
username: member.user.username,
email: member.user.email,
timeZone: member.user.timeZone,
role: member.role,
dateRanges: [] as DateRange[],
};
}
const schedule = await prisma.schedule.findUnique({
where: { id: member.user.defaultScheduleId },
select: { availability: true, timeZone: true },
});
const timeZone = schedule?.timeZone || member.user.timeZone;
const dateRanges = buildDateRanges({
dateFrom,
dateTo,
timeZone,
availability: schedule?.availability ?? [],
});
return {
id: member.user.id,
username: member.user.username,
email: member.user.email,
timeZone,
role: member.role,
dateRanges,
};
}
async function getInfoForAllTeams({ ctx, input }: GetOptions) {
const { cursor, limit } = input;
// Get all teamIds for the user
const teamIds = await prisma.membership
.findMany({
where: {
userId: ctx.user.id,
},
select: {
id: true,
},
})
.then((memberships) => memberships.map((membership) => membership.id));
if (!teamIds.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization or team." });
}
const getTotalMembers = await prisma.$queryRaw<{
count: number;
}>(Prisma.sql`
SELECT
COUNT(DISTINCT "userId") as "count"
FROM "Membership"
WHERE "teamId" IN (${Prisma.join(teamIds)})
`);
const teamMembers = await getTeamMembers({
teamIds,
cursor,
limit,
});
return {
teamMembers,
totalTeamMembers: getTotalMembers.count,
};
}
export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) => {
const { cursor, limit } = input;
const teamId = input.teamId || ctx.user.organizationId;
let teamMembers: Member[] = [];
let totalTeamMembers = 0;
if (!teamId) {
// Get all users TODO:
const teamAllInfo = await getInfoForAllTeams({ ctx, input });
teamMembers = teamAllInfo.teamMembers;
totalTeamMembers = teamAllInfo.totalTeamMembers;
} else {
const isMember = await prisma.membership.findFirst({
where: {
teamId,
userId: ctx.user.id,
},
});
if (!isMember) {
teamMembers = [];
totalTeamMembers = 0;
} else {
const { cursor, limit } = input;
totalTeamMembers = await prisma.membership.count({
where: {
teamId: teamId,
},
});
// I couldnt get this query to work direct on membership table
teamMembers = await getTeamMembers({
teamId,
cursor,
limit,
});
}
}
let nextCursor: typeof cursor | undefined = undefined;
if (teamMembers && teamMembers.length > limit) {
@@ -65,40 +196,7 @@ export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) =>
const dateFrom = dayjs(input.startDate).tz(input.loggedInUsersTz).subtract(1, "day");
const dateTo = dayjs(input.endDate).tz(input.loggedInUsersTz).add(1, "day");
const buildMembers = teamMembers?.map(async (member) => {
if (!member.user.defaultScheduleId) {
return {
id: member.user.id,
username: member.user.username,
email: member.user.email,
timeZone: member.user.timeZone,
role: member.role,
dateRanges: [] as DateRange[],
};
}
const schedule = await prisma.schedule.findUnique({
where: { id: member.user.defaultScheduleId },
select: { availability: true, timeZone: true },
});
const timeZone = schedule?.timeZone || member.user.timeZone;
const dateRanges = buildDateRanges({
dateFrom,
dateTo,
timeZone,
availability: schedule?.availability ?? [],
});
return {
id: member.user.id,
username: member.user.username,
email: member.user.email,
timeZone,
role: member.role,
dateRanges,
};
});
const buildMembers = teamMembers?.map((member) => buildMember(member, dateFrom, dateTo));
const members = await Promise.all(buildMembers);
@@ -106,7 +204,7 @@ export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) =>
rows: members || [],
nextCursor,
meta: {
totalRowCount: getTotalMembers || 0,
totalRowCount: totalTeamMembers,
},
};
};
@@ -6,6 +6,7 @@ export const ZListTeamAvailaiblityScheme = z.object({
startDate: z.string(),
endDate: z.string(),
loggedInUsersTz: z.string(),
teamId: z.number().optional(),
});
export type TListTeamAvailaiblityScheme = z.infer<typeof ZListTeamAvailaiblityScheme>;
@@ -1,6 +1,6 @@
import type { Availability as AvailabilityModel, Schedule as ScheduleModel, User } from "@prisma/client";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { Schedule } from "@calcom/types/schedule";
export const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => {
@@ -1,6 +1,7 @@
import { parseRecurringEvent } from "@calcom/lib";
import type { PrismaClient } from "@calcom/prisma";
import { bookingMinimalSelect } from "@calcom/prisma";
import type { Prisma, PrismaClient } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -72,6 +73,62 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
unconfirmed: { startTime: "asc" },
};
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus];
const { bookings, recurringInfo } = await getBookings({
user,
prisma,
passedBookingsStatusFilter,
filters: input.filters,
orderBy,
take,
skip,
});
const bookingsFetched = bookings.length;
let nextCursor: typeof skip | null = skip;
if (bookingsFetched > take) {
nextCursor += bookingsFetched;
} else {
nextCursor = null;
}
return {
bookings,
recurringInfo,
nextCursor,
};
};
const set = new Set();
const getUniqueBookings = <T extends { uid: string }>(arr: T[]) => {
const unique = arr.filter((booking) => {
const duplicate = set.has(booking.uid);
set.add(booking.uid);
return !duplicate;
});
set.clear();
return unique;
};
async function getBookings({
user,
prisma,
passedBookingsStatusFilter,
filters,
orderBy,
take,
skip,
}: {
user: { id: number; email: string };
filters: TGetInputSchema["filters"];
prisma: PrismaClient;
passedBookingsStatusFilter: Prisma.BookingWhereInput;
orderBy: Prisma.BookingOrderByWithAggregationInput;
take: number;
skip: number;
}) {
// TODO: Fix record typing
const bookingWhereInputFilters: Record<string, Prisma.BookingWhereInput> = {
teamIds: {
@@ -80,7 +137,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
eventType: {
team: {
id: {
in: input.filters?.teamIds,
in: filters?.teamIds,
},
},
},
@@ -94,7 +151,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
users: {
some: {
id: {
in: input.filters?.userIds,
in: filters?.userIds,
},
},
},
@@ -106,7 +163,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
AND: [
{
eventTypeId: {
in: input.filters?.eventTypeIds,
in: filters?.eventTypeIds,
},
},
],
@@ -114,21 +171,100 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
};
const filtersCombined: Prisma.BookingWhereInput[] =
input.filters &&
Object.keys(input.filters).map((key) => {
filters &&
Object.keys(filters).map((key) => {
return bookingWhereInputFilters[key];
});
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus];
const bookingSelect = {
...bookingMinimalSelect,
uid: true,
recurringEventId: true,
location: true,
eventType: {
select: {
slug: true,
id: true,
eventName: true,
price: true,
recurringEvent: true,
currency: true,
metadata: true,
seatsShowAttendees: true,
team: {
select: {
id: true,
name: true,
},
},
},
},
status: true,
paid: true,
payment: {
select: {
paymentOption: true,
amount: true,
currency: true,
success: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
rescheduled: true,
references: true,
isRecorded: true,
seatsReferences: {
where: {
attendee: {
email: user.email,
},
},
select: {
referenceUid: true,
attendee: {
select: {
email: true,
},
},
},
},
};
const [bookingsQuery, recurringInfoBasic, recurringInfoExtended] = await Promise.all([
const [
// Quering these in parallel to save time.
// Note that because we are applying `take` to individual queries, we will usually get more bookings then we need. It is okay to have more bookings faster than having what we need slower
bookingsQueryUserId,
bookingsQueryAttendees,
bookingsQueryTeamMember,
bookingsQuerySeatReference,
//////////////////////////
recurringInfoBasic,
recurringInfoExtended,
// We need all promises to be successful, so we are not using Promise.allSettled
] = await Promise.all([
prisma.booking.findMany({
where: {
OR: [
{
userId: user.id,
},
],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
orderBy,
take: take + 1,
skip,
}),
prisma.booking.findMany({
where: {
OR: [
{
attendees: {
some: {
@@ -136,6 +272,16 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
},
},
},
],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
orderBy,
take: take + 1,
skip,
}),
prisma.booking.findMany({
where: {
OR: [
{
eventType: {
team: {
@@ -150,6 +296,16 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
},
},
},
],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
orderBy,
take: take + 1,
skip,
}),
prisma.booking.findMany({
where: {
OR: [
{
seatsReferences: {
some: {
@@ -162,65 +318,6 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
select: {
...bookingMinimalSelect,
uid: true,
recurringEventId: true,
location: true,
eventType: {
select: {
slug: true,
id: true,
eventName: true,
price: true,
recurringEvent: true,
currency: true,
metadata: true,
seatsShowAttendees: true,
team: {
select: {
id: true,
name: true,
},
},
},
},
status: true,
paid: true,
payment: {
select: {
paymentOption: true,
amount: true,
currency: true,
success: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
rescheduled: true,
references: true,
isRecorded: true,
seatsReferences: {
where: {
attendee: {
email: user.email,
},
},
select: {
referenceUid: true,
attendee: {
select: {
email: true,
},
},
},
},
},
orderBy,
take: take + 1,
skip,
@@ -285,7 +382,25 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
}
);
const bookings = bookingsQuery.map((booking) => {
const plainBookings = getUniqueBookings(
bookingsQueryUserId
.concat(bookingsQueryAttendees)
.concat(bookingsQueryTeamMember)
.concat(bookingsQuerySeatReference)
);
// Now enrich bookings with relation data. We could have queried the relation data along with the bookings, but that would cause unnecessary queries to the database.
// Because Prisma is also going to query the select relation data sequentially, we are fine querying it separately here as it would be just 1 query instead of 4
const bookings = (
await prisma.booking.findMany({
where: {
id: {
in: plainBookings.map((booking) => booking.id),
},
},
select: bookingSelect,
})
).map((booking) => {
// If seats are enabled and the event is not set to show attendees, filter out attendees that are not the current user
if (booking.seatsReferences.length && !booking.eventType?.seatsShowAttendees) {
booking.attendees = booking.attendees.filter((attendee) => attendee.email === user.email);
@@ -303,18 +418,5 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
endTime: booking.endTime.toISOString(),
};
});
const bookingsFetched = bookings.length;
let nextCursor: typeof skip | null = skip;
if (bookingsFetched > take) {
nextCursor += bookingsFetched;
} else {
nextCursor = null;
}
return {
bookings,
recurringInfo,
nextCursor,
};
};
return { bookings, recurringInfo };
}
@@ -5,7 +5,7 @@ import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { DailyLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import getEventTypeById from "@calcom/lib/getEventTypeById";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetInputSchema } from "./get.schema";
@@ -1,4 +1,4 @@
import { type PrismaClient, Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { orderBy } from "lodash";
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import { CAL_URL } from "@calcom/lib/constants";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getBookerUrl } from "@calcom/lib/server/getBookerUrl";
import type { PrismaClient } from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -1,4 +1,3 @@
import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { NextApiResponse, GetServerSidePropsContext } from "next";
@@ -8,6 +7,7 @@ import { validateIntervalLimitOrder } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -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) => {
@@ -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;
@@ -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,
},
},
@@ -1,4 +1,4 @@
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -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;
// 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,
};
}
data.metadata = mergeMetadata({
// If we save slug, we don't need the requestedSlug anymore
requestedSlug: undefined,
...input.metadata,
});
}
}
@@ -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>;
@@ -2,7 +2,7 @@ import appStore from "@calcom/app-store";
import dayjs from "@calcom/dayjs";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService";
@@ -4,7 +4,7 @@ import { v4 as uuid } from "uuid";
import dayjs from "@calcom/dayjs";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -1,5 +1,4 @@
import type { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TListMembersInputSchema } from "./listMembers.schema";
@@ -1,9 +1,8 @@
import type { PrismaClient } from "@prisma/client";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@@ -3,8 +3,8 @@ import type { Workflow } from "@prisma/client";
import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import { SENDER_NAME } from "@calcom/lib/constants";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma/client";
import {
MembershipRole,
TimeUnit,
@@ -1,6 +1,7 @@
import type { WorkflowType } from "@calcom/ee/workflows/components/WorkflowListPage";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import type { Prisma, PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -19,7 +19,7 @@ import {
import { IS_SELF_HOSTED, SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus, WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -8,7 +8,7 @@ import {
} from "@calcom/features/bookings/lib/getBookingFields";
import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import type PrismaType from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { WorkflowStep } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -20,7 +20,7 @@ export function getSender(
export async function isAuthorized(
workflow: Pick<Workflow, "id" | "teamId" | "userId"> | null,
prisma: typeof PrismaType,
prisma: PrismaClient,
currentUserId: number,
readOnly?: boolean
) {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { IncomingMessage } from "http";
import type { Session } from "next-auth";
import type { PrismaClient } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import "./next-auth";
+5
View File
@@ -0,0 +1,5 @@
interface Window {
Meticulous?: {
isRunningAsTest: boolean;
};
}
@@ -43,6 +43,7 @@ const HorizontalTabItem = function ({
props.disabled && "pointer-events-none !opacity-30",
props.className
)}
data-testid={`horizontal-tab-${name}`}
aria-current={isCurrent ? "page" : undefined}>
{props.icon && (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -67,6 +67,7 @@ const VerticalTabItem = ({
"mr-2 h-[16px] w-[16px] stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0",
props.iconClassName
)}
data-testid="icon-component"
/>
)}
<div className="h-fit">
@@ -74,7 +75,7 @@ const VerticalTabItem = ({
<Skeleton title={t(name)} as="p" className="max-w-36 min-h-4 mt-px truncate">
{t(name)}
</Skeleton>
{props.isExternalLink ? <ExternalLink /> : null}
{props.isExternalLink ? <ExternalLink data-testid="external-link" /> : null}
</span>
{info && (
<Skeleton as="p" title={t(info)} className="max-w-44 mt-1 truncate text-xs font-normal">
@@ -88,6 +89,7 @@ const VerticalTabItem = ({
width={20}
height={20}
className="text-default h-auto w-[20px] stroke-[1.5px]"
data-testid="chevron-right"
/>
</div>
)}
@@ -0,0 +1,135 @@
/* eslint-disable playwright/missing-playwright-await */
import { fireEvent, render, screen } from "@testing-library/react";
import { PlusIcon } from "lucide-react";
import { vi } from "vitest";
import HorizontalTabs from "./HorizontalTabs";
import VerticalTabs from "./VerticalTabs";
vi.mock("@calcom/lib/hooks/useUrlMatchesCurrentUrl", () => ({
useUrlMatchesCurrentUrl() {
return {
route: "/",
pathname: "",
query: "",
asPath: "",
push: vi.fn(),
events: {
on: vi.fn(),
off: vi.fn(),
},
beforePopState: vi.fn(() => null),
prefetch: vi.fn(() => null),
};
},
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({ t: (key: string) => key, isLocaleReady: true }),
}));
describe("Tests for navigation folder", () => {
describe("Test HorizontalTabs Component", () => {
const mockTabs = [
{ name: "Tab 1", href: "/tab1" },
{ name: "Tab 2", href: "/tab2", avatar: "Avatar" },
{ name: "Tab 3", href: "/tab3" },
];
beforeEach(() => {
vi.clearAllMocks();
});
test("Should render tabs with correct name and href", () => {
render(<HorizontalTabs tabs={mockTabs} />);
mockTabs.forEach((tab) => {
const tabLabelElement = screen.getByTestId(`horizontal-tab-${tab.name}`);
expect(tabLabelElement).toBeInTheDocument();
const name = screen.getByText(tab.name);
expect(name).toBeInTheDocument();
expect(tabLabelElement).toHaveAttribute("href", tab.href);
});
});
test("Should render actions correctly", () => {
const handleClick = vi.fn();
const mockActions = <button onClick={handleClick}>Actions</button>;
render(<HorizontalTabs tabs={mockTabs} actions={mockActions} />);
const actionsElement = screen.getByText("Actions");
expect(actionsElement).toBeInTheDocument();
fireEvent.click(actionsElement);
expect(handleClick).toHaveBeenCalled();
});
});
describe("Test VerticalTabs Component", () => {
const mockTabs = [
{
name: "Tab 1",
href: "/tab1",
disableChevron: true,
disabled: true,
icon: PlusIcon,
},
{ name: "Tab 2", href: "/tab2", isExternalLink: true },
{ name: "Tab 3", href: "/tab3", info: "info" },
];
beforeEach(() => {
vi.clearAllMocks();
});
test("Should render tabs with correct name and href", () => {
render(<VerticalTabs tabs={mockTabs} />);
mockTabs.forEach((tab) => {
const tabLabelElement = screen.getByTestId(`vertical-tab-${tab.name}`);
expect(tabLabelElement).toBeInTheDocument();
const name = screen.getByText(tab.name);
expect(name).toBeInTheDocument();
expect(tabLabelElement).toHaveAttribute("href", tab.href);
});
});
test("Should render correctly if props are passed", () => {
render(<VerticalTabs tabs={mockTabs} />);
const iconElement = screen.getAllByTestId("icon-component");
const externalLink = screen.getAllByTestId("external-link");
const chevronRight = screen.getAllByTestId("chevron-right");
mockTabs.forEach((tab) => {
const tabName = screen.getByText(tab.name);
expect(tabName).toBeInTheDocument();
const aTag = screen.getByTestId(`vertical-tab-${tab.name}`);
const tabContainer = tabName.closest("a");
const infoElement = tabContainer?.querySelector("p[title='info']");
expect(chevronRight.length).toEqual(mockTabs.length - 1);
if (tab.disabled) {
expect(aTag).tabToBeDisabled();
} else {
expect(aTag).not.tabToBeDisabled();
}
if (tab.info) {
expect(infoElement).toBeInTheDocument();
} else {
expect(infoElement).toBeNull();
}
if (tab.isExternalLink) {
expect(aTag).toHaveAttribute("target", "_blank");
} else {
expect(aTag).toHaveAttribute("target", "_self");
}
});
expect(externalLink.length).toEqual(1);
expect(iconElement.length).toEqual(1);
});
});
});
@@ -0,0 +1,83 @@
import { Table } from "./Table";
import { TableActions } from "./TableActions";
import {
Table as TableNew,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
TableFooter,
TableCaption,
} from "./TableNew";
export const TableNewExampleComponent = () => (
<TableNew>
<TableHeader>
<TableRow>
<TableHead>Header Column 1</TableHead>
<TableHead>Header Column 2</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Row 1, Cell 1</TableCell>
<TableCell>Row 1, Cell 2</TableCell>
</TableRow>
<TableRow>
<TableCell>Row 2, Cell 1</TableCell>
<TableCell>Row 2, Cell 2</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Row 3(footer), Cell 1</TableCell>
<TableCell>Row 3(footer), Cell 2</TableCell>
</TableRow>
</TableFooter>
<TableCaption>Table Caption</TableCaption>
</TableNew>
);
export const TableExampleComponent = () => (
<Table>
<Table.Header>
<Table.Row>
<Table.ColumnTitle>Title Column 1</Table.ColumnTitle>
<Table.ColumnTitle>Title Column 2</Table.ColumnTitle>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Row 1, Cell 1</Table.Cell>
<Table.Cell>Row 1, Cell 2</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Row 2, Cell 1</Table.Cell>
<Table.Cell>Row 2, Cell 2</Table.Cell>
</Table.Row>
<Table.Row>
<TableActions
actions={[
{
id: "action1",
label: "Action 1",
href: "#1",
},
{
id: "action2",
label: "Action 2",
actions: [
{
id: "action3",
label: "Action 3",
href: "#nested-action",
},
],
},
]}
/>
</Table.Row>
</Table.Body>
</Table>
);
@@ -0,0 +1,48 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Table } from "./Table";
import { TableExampleComponent } from "./TableExamples";
<Meta title="UI/Table/Table" component={Table} />
<Title title="Table" suffix="Brief" subtitle="Version 1.0 — Last Update: 21 Aug 2023" />
## Definition
The `Table` component is a HTML table that utilizes Tailwind classes to enhance its styles.
## Structure
The `Table` component should be used with other table sub-components (present as object attributes) that follow the same pattern.
These components render common elements that are styled with Tailwind. These components include:
- `Body` -> (`tbody`)
- `Header` -> (`thead`)
- `Row` -> (`tr`)
- `ColumnTitle` -> (`th`)
- `Cell` -> (`td`)
<CustomArgsTable of={Table} />
<Title offset title="Table" suffix="Variants" />
<Canvas>
<Story name="Table">
{(props) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<TableExampleComponent />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>
+3 -27
View File
@@ -2,9 +2,9 @@
import { fireEvent, render } from "@testing-library/react";
import { vi } from "vitest";
import { Table, TableActions } from "@calcom/ui";
import { TableActions } from "@calcom/ui";
import { TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./TableNew";
import { TableNewExampleComponent } from "./TableExamples";
const mockActions = [
{
@@ -21,31 +21,7 @@ const mockActions = [
describe("tests for Table component", () => {
test("Should render Table component correctly", () => {
const { getByRole, getByText } = render(
<>
<Table>
<>
<TableHeader>
<TableRow>
<TableHead>Header Column 1</TableHead>
<TableHead>Header Column 2</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Row 1, Cell 1</TableCell>
<TableCell>Row 1, Cell 2</TableCell>
</TableRow>
<TableRow>
<TableCell>Row 2, Cell 1</TableCell>
<TableCell>Row 2, Cell 2</TableCell>
</TableRow>
</TableBody>
<TableCaption>Table Caption</TableCaption>
</>
</Table>
</>
);
const { getByRole, getByText } = render(<TableNewExampleComponent />);
const headerElement1 = getByRole("columnheader", { name: "Header Column 1" });
const headerElement2 = getByRole("columnheader", { name: "Header Column 2" });
@@ -0,0 +1,50 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import { TableNewExampleComponent } from "./TableExamples";
import { Table } from "./TableNew";
<Meta title="UI/Table/TableNew" component={Table} />
<Title title="Table New" suffix="Brief" subtitle="Version 1.0 — Last Update: 21 Aug 2023" />
## Definition
The `Table` component (from TableNew.tsx) is a HTML table that utilizes Tailwind classes to enhance its styles.
## Structure
The `Table` component should be used with other table sub-components (exported in the same file) that follow the same pattern.
These components render common elements that are styled with Tailwind. These components include:
- `TableBody` -> (`tbody`)
- `TableHeader` -> (`thead`)
- `TableRow` -> (`tr`)
- `TableHead` -> (`th`)
- `TableCell` -> (`td`)
- `TableFooter` -> (`tfoot`)
- `TableCaption` -> (`caption`)
<CustomArgsTable of={Table} />
<Title offset title="Table New" suffix="Variants" />
<Canvas>
<Story name="Table New">
{(props) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<TableNewExampleComponent />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>
+10
View File
@@ -44,6 +44,16 @@ vi.mock("@calcom/lib/OgImages", async () => {
return {};
});
expect.extend({
tabToBeDisabled(received) {
const isDisabled = received.classList.contains("pointer-events-none");
return {
pass: isDisabled,
message: () => `Expected tab to be disabled`,
};
},
});
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),

Some files were not shown because too many files have changed in this diff Show More