Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e50625165 | ||
|
|
c78eb752a7 | ||
|
|
1c20bdcecf | ||
|
|
338756439f | ||
|
|
56050b994d | ||
|
|
c11ecbb323 | ||
|
|
7f23ae156b | ||
|
|
33250652b3 | ||
|
|
a65e18d92d | ||
|
|
d0f7085cb8 | ||
|
|
ea8437b4f7 | ||
|
|
877cd4cdff | ||
|
|
bae3bd76e5 | ||
|
|
8b24995d52 | ||
|
|
7dc6df2ad6 | ||
|
|
6c4b1154b8 | ||
|
|
1099bad930 | ||
|
|
dbd5d27d9d | ||
|
|
45c4f4789b | ||
|
|
07f42057fc | ||
|
|
79ee7c41b2 | ||
|
|
57e6971942 | ||
|
|
bd6ca21e02 | ||
|
|
f19a1926b4 |
+1
-1
@@ -266,7 +266,7 @@ CALCOM_WEBHOOK_SECRET=""
|
||||
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
|
||||
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||
# Key should match on Cal.com and your application
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# must be 24 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
@@ -32,6 +32,7 @@ type AppListCardProps = {
|
||||
invalidCredential?: boolean;
|
||||
children?: ReactNode;
|
||||
credentialOwner?: CredentialOwner;
|
||||
className?: string;
|
||||
} & ShouldHighlight;
|
||||
|
||||
const schema = z.object({ hl: z.string().optional() });
|
||||
@@ -50,6 +51,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
invalidCredential,
|
||||
children,
|
||||
credentialOwner,
|
||||
className,
|
||||
} = props;
|
||||
const {
|
||||
data: { hl },
|
||||
@@ -83,7 +85,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
}, [highlight, pathname, router, searchParams, shouldHighlight]);
|
||||
|
||||
return (
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100", className)}>
|
||||
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||
{logo ? (
|
||||
<img
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
List,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
ShellSubHeading,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -27,6 +28,7 @@ type Props = {
|
||||
onChanged: () => unknown | Promise<unknown>;
|
||||
fromOnboarding?: boolean;
|
||||
destinationCalendarId?: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
@@ -70,8 +72,9 @@ function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||
suspense: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const { fromOnboarding } = props;
|
||||
const { fromOnboarding, isLoading } = props;
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
@@ -82,74 +85,94 @@ function ConnectedCalendarsList(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<List>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<AppListCard
|
||||
shouldHighlight
|
||||
slug={item.integration.slug}
|
||||
title={item.integration.name}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.email ?? item.integration.description}
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="border-subtle border-t">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-4 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="border-subtle mt-6 rounded-lg border">
|
||||
<div className="border-subtle border-b p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className="flex items-center">
|
||||
<AdditionalCalendarSelector isLoading={isLoading} />
|
||||
</div>
|
||||
</AppListCard>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("something_went_wrong")}
|
||||
message={
|
||||
<span>
|
||||
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
|
||||
{t("calendar_error")}
|
||||
</span>
|
||||
}
|
||||
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||
actions={
|
||||
<div className="flex w-32 justify-end md:pr-1">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<List noBorderTreatment className="p-6 pt-2">
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<AppListCard
|
||||
shouldHighlight
|
||||
slug={item.integration.slug}
|
||||
title={item.integration.name}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.email ?? item.integration.description}
|
||||
className="border-subtle mt-4 rounded-lg border"
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="border-subtle border-t">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-4 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
</AppListCard>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("something_went_wrong")}
|
||||
message={
|
||||
<span>
|
||||
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
|
||||
{t("calendar_error")}
|
||||
</span>
|
||||
}
|
||||
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||
actions={
|
||||
<div className="flex w-32 justify-end md:pr-1">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={props.onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -187,42 +210,28 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
|
||||
<>
|
||||
{heading && (
|
||||
<div className="border-default flex flex-col gap-6 rounded-md border p-7">
|
||||
<ShellSubHeading
|
||||
title={t("calendar")}
|
||||
subtitle={t("installed_app_calendar_description")}
|
||||
className="mb-0 flex flex-wrap items-center gap-4 sm:flex-nowrap md:mb-3 md:gap-0"
|
||||
actions={
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className="flex items-center">
|
||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="bg-muted border-subtle flex justify-between rounded-md border p-4">
|
||||
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
<div className="bg-default border-subtle relative rounded-md border p-1.5">
|
||||
<Calendar className="text-default h-8 w-8" strokeWidth="1" />
|
||||
<Plus
|
||||
className="text-emphasis absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-6/12">
|
||||
<h1 className="text-emphasis text-sm font-semibold">{t("create_events_on")}</h1>
|
||||
<p className="text-default text-sm font-normal">{t("set_calendar")}</p>
|
||||
</div>
|
||||
<div className="justify-end md:w-6/12">
|
||||
<DestinationCalendarSelector
|
||||
onChange={mutation.mutate}
|
||||
hidePlaceholder
|
||||
isLoading={mutation.isLoading}
|
||||
value={data.destinationCalendar?.externalId}
|
||||
hideAdvancedText
|
||||
/>
|
||||
<>
|
||||
<div className="border-subtle mb-6 mt-8 rounded-lg border">
|
||||
<div className="p-6">
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
|
||||
<p className="text-subtle text-sm leading-tight">
|
||||
{t("add_to_calendar_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border-y-0 p-6">
|
||||
<div>
|
||||
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={data.destinationCalendar?.externalId}
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,8 +239,9 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
onChanged={onChanged}
|
||||
fromOnboarding={fromOnboarding}
|
||||
destinationCalendarId={data.destinationCalendar?.externalId}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : fromOnboarding ? (
|
||||
|
||||
@@ -262,15 +262,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const showCheckRecordingButton =
|
||||
isPast &&
|
||||
isConfirmed &&
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: t("view_recordings"),
|
||||
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
color: showCheckRecordingButton ? "secondary" : "primary",
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
@@ -298,7 +304,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{showRecordingsButtons && (
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
@@ -478,7 +484,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
@@ -517,8 +525,8 @@ const RecurringBookingsTooltip = ({
|
||||
return (
|
||||
recurringDate >= now &&
|
||||
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
||||
.map((date) => date.toDateString())
|
||||
.includes(recurringDate.toDateString())
|
||||
.map((date) => date.toString())
|
||||
.includes(recurringDate.toString())
|
||||
);
|
||||
}).length;
|
||||
|
||||
@@ -535,8 +543,8 @@ const RecurringBookingsTooltip = ({
|
||||
const pastOrCancelled =
|
||||
aDate < now ||
|
||||
booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
||||
.map((date) => date.toDateString())
|
||||
.includes(aDate.toDateString());
|
||||
.map((date) => date.toString())
|
||||
.includes(aDate.toString());
|
||||
return (
|
||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||
{formatTime(aDate, userTimeFormat, userTimeZone)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -130,7 +130,6 @@
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"turndown": "^7.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"web3": "^1.7.5",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
const querySchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
});
|
||||
|
||||
const checkoutSessionMetadataSchema = z.object({
|
||||
teamName: z.string(),
|
||||
teamSlug: z.string(),
|
||||
userId: z.string().transform(Number),
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { session_id } = querySchema.parse(req.query);
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
|
||||
expand: ["subscription"],
|
||||
});
|
||||
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
||||
|
||||
const subscription = checkoutSession.subscription as Stripe.Subscription;
|
||||
|
||||
if (checkoutSession.payment_status !== "paid")
|
||||
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
||||
|
||||
// Let's query to ensure that the team metadata carried over from the checkout session.
|
||||
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
|
||||
|
||||
if (!parseCheckoutSessionMetadata.success) {
|
||||
console.error(
|
||||
"Team metadata not found in checkout session",
|
||||
parseCheckoutSessionMetadata.error,
|
||||
checkoutSession.id
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkoutSession.metadata?.userId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Can't publish team/org without userId",
|
||||
});
|
||||
}
|
||||
|
||||
const checkoutSessionMetadata = parseCheckoutSessionMetadata.success
|
||||
? parseCheckoutSessionMetadata.data
|
||||
: {
|
||||
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
|
||||
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
|
||||
userId: checkoutSession.metadata.userId,
|
||||
};
|
||||
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: checkoutSessionMetadata.teamName,
|
||||
slug: checkoutSessionMetadata.teamSlug,
|
||||
members: {
|
||||
create: {
|
||||
userId: checkoutSessionMetadata.userId as number,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: subscription.id || null,
|
||||
subscriptionItemId: subscription.items.data[0].id || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
// closeComUpdateTeam(prevTeam, team);
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -54,8 +54,8 @@ const IntegrationsContainer = ({
|
||||
automation: Share2,
|
||||
analytics: BarChart,
|
||||
payment: CreditCard,
|
||||
web3: BarChart, // deprecated
|
||||
other: Grid,
|
||||
web3: CreditCard, // deprecated
|
||||
video: Video, // deprecated
|
||||
messaging: Mail,
|
||||
crm: Contact,
|
||||
|
||||
@@ -1244,6 +1244,7 @@ async function getRecurringBookings(recurringEventId: string | null) {
|
||||
const recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
|
||||
@@ -1,37 +1,12 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemTitle,
|
||||
Meta,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
showToast,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
import { Plus, Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { Button, Meta, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
@@ -62,37 +37,6 @@ const AddCalendarButton = () => {
|
||||
|
||||
const CalendarsView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
|
||||
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
|
||||
integration: string;
|
||||
externalId: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data?.destinationCalendar) {
|
||||
setSelectedDestinationCalendar({
|
||||
integration: query.data.destinationCalendar.integration,
|
||||
externalId: query.data.destinationCalendar.externalId,
|
||||
});
|
||||
}
|
||||
}, [query?.isLoading, query?.data?.destinationCalendar]);
|
||||
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
showToast(t("calendar_updated_successfully"), "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -102,167 +46,9 @@ const CalendarsView = () => {
|
||||
CTA={<AddCalendarButton />}
|
||||
borderInShellHeader={false}
|
||||
/>
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
const isDestinationUpdateBtnDisabled =
|
||||
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||
return data.connectedCalendars.length ? (
|
||||
<div>
|
||||
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
<p className="text-subtle text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
||||
</div>
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||
<div>
|
||||
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={selectedDestinationCalendarOption?.externalId}
|
||||
onChange={(option) => {
|
||||
setSelectedDestinationCalendar(option);
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
disabled={isDestinationUpdateBtnDisabled}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
|
||||
}}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
|
||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
|
||||
</div>
|
||||
|
||||
<List
|
||||
className="border-subtle flex flex-col gap-6 rounded-b-lg border border-t-0 p-6"
|
||||
noBorderTreatment>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && item.error.message && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
key={item.credentialId}
|
||||
title={t("calendar_connection_fail")}
|
||||
message={item.error.message}
|
||||
className="mb-4 mt-4"
|
||||
actions={
|
||||
<>
|
||||
{/* @TODO: add a reconnect button, that calls add api and delete old credential */}
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={() => query.refetch()}
|
||||
buttonProps={{
|
||||
className: "border border-default py-[2px]",
|
||||
color: "secondary",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem className="flex-col rounded-lg">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 p-4 rtl:space-x-reverse">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
item.integration.logo && (
|
||||
<img
|
||||
className={classNames(
|
||||
"h-10 w-10",
|
||||
item.integration.logo.includes("-dark") && "dark:invert"
|
||||
)}
|
||||
src={item.integration.logo}
|
||||
alt={item.integration.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
||||
<Link href={`/apps/${item.integration.slug}`}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
{data?.destinationCalendar?.credentialId === item.credentialId && (
|
||||
<Badge variant="green">Default</Badge>
|
||||
)}
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{item.integration.description}</ListItemText>
|
||||
</div>
|
||||
<div>
|
||||
<DisconnectIntegration
|
||||
trashIcon
|
||||
credentialId={item.credentialId}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t">
|
||||
<p className="text-subtle px-2 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-4 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
credentialId={cal.credentialId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isChecked={
|
||||
cal.isSelected || cal.externalId === data?.destinationCalendar?.externalId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_calendar_installed")}
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||
className="mt-6"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
error={() => {
|
||||
return (
|
||||
<Alert
|
||||
message={
|
||||
<Trans i18nKey="fetching_calendars_error">
|
||||
An error ocurred while fetching your Calendars.
|
||||
<a className="cursor-pointer underline" onClick={() => query.refetch()}>
|
||||
try again
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
}
|
||||
severity="error"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<CalendarListContainer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
// there should be one, otherwise we throw
|
||||
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -37,7 +37,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
// Test when a user changes his email after starting the password reset flow
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}-2@example.com`,
|
||||
@@ -54,7 +54,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
email: `${user.username}-2@example.com`,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
// we're not logging in to the UI to speed up test performance.
|
||||
const updatedUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -84,10 +84,10 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
|
||||
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
await page.waitForSelector("text=That request is expired.");
|
||||
await expect(page.locator(`text=Whoops`)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -295,3 +295,11 @@ export function generateTotpCode(email: string) {
|
||||
totp.options = { step: 90 };
|
||||
return totp.generate(secret);
|
||||
}
|
||||
|
||||
export async function fillStripeTestCheckout(page: Page) {
|
||||
await page.fill("[name=cardNumber]", "4242424242424242");
|
||||
await page.fill("[name=cardExpiry]", "12/30");
|
||||
await page.fill("[name=cardCvc]", "111");
|
||||
await page.fill("[name=billingName]", "Stripe Stripeson");
|
||||
await page.click(".SubmitButton--complete-Shimmer");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
import { localize } from "./lib/testUtils";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
fillStripeTestCheckout,
|
||||
localize,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
@@ -21,18 +27,20 @@ test.describe("Managed Event Types tests", () => {
|
||||
|
||||
await test.step("Managed event option exists for team admin", async () => {
|
||||
// Filling team creation form wizard
|
||||
await page.locator('input[name="name"]').waitFor();
|
||||
await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`);
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
// wait for the second member to be added to the pending-member-list.
|
||||
await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor();
|
||||
// and publish
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL("/settings/teams/**");
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
// Going to create an event type
|
||||
await page.goto("/event-types");
|
||||
await page.getByTestId("new-event-type").click();
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
fillStripeTestCheckout,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
testName,
|
||||
todo,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Teams - NonOrg", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create Team").click();
|
||||
await page.waitForURL("/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
test("Team Onboarding Invite Members", async ({ page, users }) => {
|
||||
const user = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await user.getFirstTeam();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
page.goto(`/settings/teams/${team.id}/onboard-members`);
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
@@ -50,9 +48,9 @@ test.describe("Teams - NonOrg", () => {
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can publish team", async () => {
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await test.step("Finishing brings you to team profile page", async () => {
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
@@ -66,7 +64,6 @@ test.describe("Teams - NonOrg", () => {
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
@@ -74,11 +71,14 @@ test.describe("Teams - NonOrg", () => {
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
@@ -102,18 +102,20 @@ test.describe("Teams - NonOrg", () => {
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
@@ -134,7 +136,7 @@ test.describe("Teams - NonOrg", () => {
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
@@ -164,8 +166,7 @@ test.describe("Teams - NonOrg", () => {
|
||||
await page.goto("/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
await page.locator("text=Continue").click();
|
||||
await expect(page.locator("[data-testid=alert]")).toBeVisible();
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// cleanup
|
||||
const org = await owner.getOrgMembership();
|
||||
@@ -174,11 +175,9 @@ test.describe("Teams - NonOrg", () => {
|
||||
});
|
||||
|
||||
test("Can create team with same name as user", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
const ownerObj = { username: uniqueName, name: uniqueName, useExactUsername: true };
|
||||
|
||||
const user = await users.create(ownerObj);
|
||||
const uniqueName = user.username!;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
@@ -189,11 +188,14 @@ test.describe("Teams - NonOrg", () => {
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can access user and team with same slug", async () => {
|
||||
@@ -210,13 +212,11 @@ test.describe("Teams - NonOrg", () => {
|
||||
await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName);
|
||||
|
||||
// cleanup team
|
||||
const team = await prisma.team.findFirst({ where: { slug: uniqueName } });
|
||||
await prisma.team.delete({ where: { id: team?.id } });
|
||||
await prisma.team.deleteMany({ where: { slug: uniqueName } });
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
@@ -224,11 +224,14 @@ test.describe("Teams - NonOrg", () => {
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
@@ -278,45 +281,43 @@ test.describe("Teams - Org", () => {
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
|
||||
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.locator("text=Finish").click();
|
||||
await page.waitForURL("/settings/teams");
|
||||
await page.getByTestId("publish-button").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator('[data-testid="team-list-item-link"]').click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("text=Disband Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.getByTestId("disband-team-button").click();
|
||||
await page.getByTestId("dialog-confirmation").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
@@ -361,13 +362,13 @@ test.describe("Teams - Org", () => {
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
|
||||
@@ -380,18 +381,20 @@ test.describe("Teams - Org", () => {
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
@@ -402,17 +405,17 @@ test.describe("Teams - Org", () => {
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The person who booked the meeting should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر Tandem.",
|
||||
"cal_provide_video_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر الفيديو على Daily.",
|
||||
"cal_provide_jitsi_meeting_url": "سنوفر لك رابطًا للاجتماع عبر Jitsi Meet.",
|
||||
"cal_provide_huddle01_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر الفيديو على Huddle01 web3.",
|
||||
"cal_provide_huddle01_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر الفيديو على Huddle01.",
|
||||
"cal_provide_teams_meeting_url": "سيوفر {{appName}} عنوان URL لاجتماعات MS Teams. ملاحظة: يجب أن يكون لديك حساب عمل أو حساب مدرسة",
|
||||
"require_payment": "يلزم الدفع",
|
||||
"you_need_to_add_a_name": "تحتاج إلى إضافة اسم",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "إضافة تطبيق تحليلات لصفحات الحجز الخاصة بك",
|
||||
"no_category_apps_description_automation": "إضافة تطبيق أتمتة لاستخدامه",
|
||||
"no_category_apps_description_other": "أضف أي نوع آخر من التطبيقات للقيام بأي شيء",
|
||||
"no_category_apps_description_web3": "إضافة تطبيق web3 لصفحات الحجز لديك",
|
||||
"no_category_apps_description_messaging": "إضافة تطبيق مراسلة لإعداد الإشعارات والتذكيرات المخصصة",
|
||||
"no_category_apps_description_crm": "إضافة تطبيق CRM لتتبع من قابلته",
|
||||
"installed_app_calendar_description": "قم بتعيين التقويمات للتحقق من وجود تعارضات لمنع الحجوزات المزدوجة.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "جميع تطبيقاتك المثبتة من الفئات الأخرى.",
|
||||
"installed_app_conferencing_description": "تكوين تطبيقات المؤتمرات المراد استخدامها",
|
||||
"installed_app_automation_description": "تهيئة تطبيقات الأتمتة التي سيتم استخدامها",
|
||||
"installed_app_web3_description": "تهيئة تطبيقات web3 التي يمكن استخدامها لصفحات الحجز لديك",
|
||||
"installed_app_messaging_description": "تكوين تطبيقات المراسلة المراد استخدامها لإعداد الإشعارات والتذكيرات المخصصة",
|
||||
"installed_app_crm_description": "تهيئة تطبيقات CRM التي يمكن استخدامها لتتبع من قابلته",
|
||||
"analytics": "التحليلات",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "صورة الملف الشخصي",
|
||||
"upload": "تحميل",
|
||||
"add_profile_photo": "إضافة صورة الملف الشخصي",
|
||||
"web3": "Web3",
|
||||
"token_address": "عنوان الرمز المميز",
|
||||
"blockchain": "سلسلة الكتل",
|
||||
"old_password": "كلمة مرور قديمة",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "توصيل تطبيقات الأتمتة",
|
||||
"connect_analytics_apps": "توصيل تطبيقات التحليلات",
|
||||
"connect_other_apps": "ربط تطبيقات أخرى",
|
||||
"connect_web3_apps": "ربط تطبيقات web3",
|
||||
"connect_messaging_apps": "ربط تطبيقات المراسلة",
|
||||
"connect_crm_apps": "ربط تطبيقات CRM",
|
||||
"current_step_of_total": "الخطوة {{currentStep}} من {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} poskytne URL Tandem meetingu.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} poskytne URL Daily video meetingu.",
|
||||
"cal_provide_jitsi_meeting_url": "{{appName}} poskytne URL Jitsi Meet video meetingu.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} poskytne URL Huddle01 web3 video meetingu.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} poskytne URL Huddle01 video meetingu.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} poskytne URL schůzky aplikace MS Teams. POZNÁMKA: MUSÍ SE JEDNAT O PRACOVNÍ NEBO ŠKOLNÍ ÚČET",
|
||||
"require_payment": "Vyžadovat platbu",
|
||||
"you_need_to_add_a_name": "Musíte přidat název",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Přidejte analytickou aplikaci pro vaše rezervační stránky",
|
||||
"no_category_apps_description_automation": "Přidejte aplikaci pro automatizaci",
|
||||
"no_category_apps_description_other": "Přidejte jakýkoli jiný typ aplikace pro nejrůznější činnosti",
|
||||
"no_category_apps_description_web3": "Přidejte aplikaci web3 pro vaše rezervační stránky",
|
||||
"no_category_apps_description_messaging": "Přidejte aplikaci pro zasílání zpráv a nastavte vlastní oznámení a připomenutí",
|
||||
"no_category_apps_description_crm": "Přidejte aplikaci CRM, ať jste v obraze, s kým jste se setkali",
|
||||
"installed_app_calendar_description": "Nastavte si kalendáře, ať můžete kontrolovat konflikty a zabránit tak dvojím rezervacím.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Všechny vaše nainstalované aplikace z ostatních kategorií.",
|
||||
"installed_app_conferencing_description": "Nakonfigurujte konferenční aplikace, které chcete používat",
|
||||
"installed_app_automation_description": "Konfigurovat aplikace pro automatizaci",
|
||||
"installed_app_web3_description": "Nakonfigurujte aplikace web3, které se mají použít pro vaše rezervační stránky",
|
||||
"installed_app_messaging_description": "Nakonfigurujte aplikace pro zasílání zpráv a nastavte vlastní oznámení a připomenutí",
|
||||
"installed_app_crm_description": "Nakonfigurujte aplikace CRM, které se mají používat ke sledování toho, s kým jste se setkali",
|
||||
"analytics": "Analytické nástroje",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profilová fotka",
|
||||
"upload": "Nahrát",
|
||||
"add_profile_photo": "Přidat profilovou fotku",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adresa tokenu",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Staré heslo",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Připojit aplikace pro automatizaci",
|
||||
"connect_analytics_apps": "Připojit analytické aplikace",
|
||||
"connect_other_apps": "Propojit další aplikace",
|
||||
"connect_web3_apps": "Připojení aplikací web3",
|
||||
"connect_messaging_apps": "Připojení aplikací pro zasílání zpráv",
|
||||
"connect_crm_apps": "Připojení aplikací CRM",
|
||||
"current_step_of_total": "Krok {{currentStep}} / {{maxSteps}}",
|
||||
|
||||
@@ -681,7 +681,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} angiver en URL-adresse for Tandem-mødet.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} angiver en URL-adresse for videomødet.",
|
||||
"cal_provide_jitsi_meeting_url": "Vi genererer en Jitsi Meet URL til dig.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} vil levere en Huddle01 web3 videomøde-URL.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} vil levere en Huddle01 videomøde-URL.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} vil levere en MS Teams møde URL. BEMÆRK: SKAL HA EN ARBEJDS- ELLER SKOLE KONTO",
|
||||
"require_payment": "Kræv Betaling",
|
||||
"commission_per_transaction": "provision pr. transaktion",
|
||||
@@ -770,13 +770,11 @@
|
||||
"no_category_apps_description_analytics": "Tilføj en analyseapp til dine bookingsider",
|
||||
"no_category_apps_description_automation": "Tilføj en automatiseringsapp til brug",
|
||||
"no_category_apps_description_other": "Tilføj enhver anden type app for at gøre alle mulige ting",
|
||||
"no_category_apps_description_web3": "Tilføj en web3 app til dine bookingsider",
|
||||
"installed_app_calendar_description": "Indstil kalenderne til at tjekke for konflikter, for at forhindre dobbeltbookinger.",
|
||||
"installed_app_payment_description": "Indstil hvilke betalingsbehandlingstjenester der skal bruges, når du opkræver betaling fra dine kunder.",
|
||||
"installed_app_analytics_description": "Indstil hvilke analyseapps der skal bruges til dine bookingsider",
|
||||
"installed_app_other_description": "Alle dine installerede apps fra andre kategorier.",
|
||||
"installed_app_automation_description": "Indstil hvilke automatiseringsapps der skal bruges",
|
||||
"installed_app_web3_description": "Indstil hvilke web3 apps der skal bruges til dine bookingsider",
|
||||
"analytics": "Analyser",
|
||||
"empty_installed_apps_headline": "Ingen apps installeret",
|
||||
"empty_installed_apps_description": "Apps giver dig mulighed for at forbedre din arbejdsgang og forbedre dit planlægningsliv betydeligt.",
|
||||
@@ -1132,7 +1130,6 @@
|
||||
"profile_picture": "Profilbillede",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Tilføj profilbillede",
|
||||
"web3": "Web3",
|
||||
"token_address": "Token Adresse",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Gammel adgangskode",
|
||||
@@ -1171,7 +1168,6 @@
|
||||
"connect_automation_apps": "Tilslut automatiseringsapps",
|
||||
"connect_analytics_apps": "Tilslut analyseapps",
|
||||
"connect_other_apps": "Tilslut andre apps",
|
||||
"connect_web3_apps": "Tilslut web3 apps",
|
||||
"current_step_of_total": "Trin {{currentStep}} af {{maxSteps}}",
|
||||
"add_variable": "Tilføj variabel",
|
||||
"custom_phone_number": "Brugerdefineret telefonnummer",
|
||||
@@ -1534,5 +1530,7 @@
|
||||
"this_will_be_the_placeholder": "Dette vil være pladsholderen",
|
||||
"verification_code": "Bekræftelseskode",
|
||||
"verify": "Bekræft",
|
||||
"confirm_your_details": "Bekræft dine oplysninger",
|
||||
"overlay_my_calendar": "Vis min kalender",
|
||||
"need_help": "Brug for hjælp?"
|
||||
}
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} wird eine Tandem Meeting URL zur Verfügung stellen.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} wird eine Video-Meeting URL zur Verfügung stellen.",
|
||||
"cal_provide_jitsi_meeting_url": "Cal stellt eine Jitsi Meet URL zur Verfügung.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} wird eine Huddle01 web3 Video-Meeting URL zur Verfügung stellen.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} wird eine Huddle01 Video-Meeting URL zur Verfügung stellen.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} wird eine MS Teams Meeting-URL zur Verfügung stellen. HINWEIS: SIE BRAUCHEN EIN BERUFS- ODER SCHULKONTO",
|
||||
"require_payment": "Zahlung erforderlich",
|
||||
"you_need_to_add_a_name": "Sie müssen einen Namen hinzufügen",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Fügen Sie eine Analyse-App für Ihre Buchungsseiten hinzu",
|
||||
"no_category_apps_description_automation": "Fügen Sie eine Automatisierungs-App hinzu, die verwendet werden soll",
|
||||
"no_category_apps_description_other": "Füge jede beliebige andere Art von App hinzu, um alle möglichen Dinge zu tun",
|
||||
"no_category_apps_description_web3": "Fügen Sie eine web3-App für Ihre Buchungsseiten hinzu",
|
||||
"no_category_apps_description_messaging": "Fügen Sie eine Nachrichten-App hinzu, um benutzerdefinierte Benachrichtigungen und Erinnerungen einzurichten",
|
||||
"no_category_apps_description_crm": "Fügen Sie eine CRM-App hinzu, um einen Überblick über die Personen zu behalten, mit denen Sie sich getroffen haben",
|
||||
"installed_app_calendar_description": "Legen Sie den/die Kalender in denen nach Konflikten gesucht werden sollen fest, um Doppelbuchungen zu vermeiden.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Alle von Ihnen installierten Apps aus anderen Kategorien.",
|
||||
"installed_app_conferencing_description": "Konfigurieren Sie, welche Konferenz-Apps verwendet werden sollen",
|
||||
"installed_app_automation_description": "Konfigurieren Sie, welche Automatisierungs-Apps verwendet werden sollen",
|
||||
"installed_app_web3_description": "Konfigurieren Sie, welche web3-Apps für Ihre Buchungsseiten verwendet werden sollen",
|
||||
"installed_app_messaging_description": "Konfigurieren Sie zur Einrichtung benutzerdefinierter Benachrichtigungen und Erinnerungen, welche Nachrichten-Apps verwendet werden sollen",
|
||||
"installed_app_crm_description": "Konfigurieren Sie, welche CRM-Apps verwendet werden sollen, um einen Überblick über die Personen zu behalten, mit denen Sie sich getroffen haben",
|
||||
"analytics": "Analyse",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profilbild",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Profilbild hinzufügen",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adresse des Token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Altes Passwort",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Automatisierungs-Apps verbinden",
|
||||
"connect_analytics_apps": "Analyse-Apps verbinden",
|
||||
"connect_other_apps": "Andere Apps verbinden",
|
||||
"connect_web3_apps": "web3-Apps verbinden",
|
||||
"connect_messaging_apps": "Nachrichten-Apps verbinden",
|
||||
"connect_crm_apps": "CRM-Apps verbinden",
|
||||
"current_step_of_total": "Schritt {{currentStep}} von {{maxSteps}}",
|
||||
|
||||
@@ -818,7 +818,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} will provide a Tandem meeting URL.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} will provide a video meeting URL.",
|
||||
"cal_provide_jitsi_meeting_url": "We will generate a Jitsi Meet URL for you.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} will provide a Huddle01 web3 video meeting URL.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} will provide a Huddle01 video meeting URL.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} will provide a MS Teams meeting URL. NOTE: MUST HAVE A WORK OR SCHOOL ACCOUNT",
|
||||
"require_payment": "Require Payment",
|
||||
"you_need_to_add_a_name": "You need to add a name",
|
||||
@@ -922,7 +922,6 @@
|
||||
"no_category_apps_description_analytics": "Add an analytics app for your booking pages",
|
||||
"no_category_apps_description_automation": "Add an automation app to use",
|
||||
"no_category_apps_description_other": "Add any other type of app to do all sorts of things",
|
||||
"no_category_apps_description_web3": "Add a web3 app for your booking pages",
|
||||
"no_category_apps_description_messaging": "Add a messaging app to set up custom notifications & reminders",
|
||||
"no_category_apps_description_crm": "Add a CRM app to keep track of who you've met with",
|
||||
"installed_app_calendar_description": "Set the calendars to check for conflicts to prevent double bookings.",
|
||||
@@ -931,7 +930,6 @@
|
||||
"installed_app_other_description": "All your installed apps from other categories.",
|
||||
"installed_app_conferencing_description": "Configure which conferencing apps to use",
|
||||
"installed_app_automation_description": "Configure which automation apps to use",
|
||||
"installed_app_web3_description": "Configure which web3 apps to use for your booking pages",
|
||||
"installed_app_messaging_description": "Configure which messaging apps to use for setting up custom notifications & reminders",
|
||||
"installed_app_crm_description": "Configure which CRM apps to use for keeping track of who you've met with",
|
||||
"analytics": "Analytics",
|
||||
@@ -1324,7 +1322,6 @@
|
||||
"profile_picture": "Profile Picture",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Add profile photo",
|
||||
"web3": "Web3",
|
||||
"token_address": "Token Address",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Old password",
|
||||
@@ -1366,7 +1363,6 @@
|
||||
"connect_automation_apps": "Connect automation apps",
|
||||
"connect_analytics_apps": "Connect analytics apps",
|
||||
"connect_other_apps": "Connect other apps",
|
||||
"connect_web3_apps": "Connect web3 apps",
|
||||
"connect_messaging_apps": "Connect messaging apps",
|
||||
"connect_crm_apps": "Connect CRM apps",
|
||||
"current_step_of_total": "Step {{currentStep}} of {{maxSteps}}",
|
||||
@@ -1556,6 +1552,9 @@
|
||||
"member_already_invited": "Member has already been invited",
|
||||
"already_in_use_error": "Username already in use",
|
||||
"enter_email_or_username": "Enter an email or username",
|
||||
"enter_email": "Enter an email",
|
||||
"enter_emails": "Enter emails",
|
||||
"too_many_invites": "You are limited to inviting a maximum of {{nbUsers}} users at once.",
|
||||
"team_name_taken": "This name is already taken",
|
||||
"must_enter_team_name": "Must enter a team name",
|
||||
"team_url_required": "Must enter a team URL",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Añada una aplicación de análisis para sus páginas de reserva",
|
||||
"no_category_apps_description_automation": "Añada una aplicación de automatización para utilizar",
|
||||
"no_category_apps_description_other": "Añade cualquier otro tipo de aplicación para hacer todo tipo de tareas",
|
||||
"no_category_apps_description_web3": "Agregue una aplicación web3 para sus páginas de reserva",
|
||||
"no_category_apps_description_messaging": "Agregue una aplicación de mensajería para configurar notificaciones y recordatorios personalizados",
|
||||
"no_category_apps_description_crm": "Agregue una aplicación CRM para realizar un seguimiento de con quiénes se ha reunido",
|
||||
"installed_app_calendar_description": "Configure el(los) calendario(s) para comprobar conflictos y evitar reservas duplicadas.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Todas tus aplicaciones instaladas de otras categorías.",
|
||||
"installed_app_conferencing_description": "Configure qué aplicaciones de conferencia usará",
|
||||
"installed_app_automation_description": "Configure qué aplicaciones de automatización va a utilizar",
|
||||
"installed_app_web3_description": "Configure qué aplicaciones web3 usar para sus páginas de reserva",
|
||||
"installed_app_messaging_description": "Configure qué aplicaciones de mensajería usar para configurar notificaciones y recordatorios personalizados",
|
||||
"installed_app_crm_description": "Configure qué aplicaciones de CRM usar para realizar un seguimiento de las personas con las que se ha reunido",
|
||||
"analytics": "Análisis",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Imagen de perfil",
|
||||
"upload": "Cargar",
|
||||
"add_profile_photo": "Agregar foto de perfil",
|
||||
"web3": "Web3",
|
||||
"token_address": "Dirección del token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Contraseña anterior",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Conecte aplicaciones de automatización",
|
||||
"connect_analytics_apps": "Conecte aplicaciones de análisis",
|
||||
"connect_other_apps": "Conectar otras aplicaciones",
|
||||
"connect_web3_apps": "Conectar aplicaciones web3",
|
||||
"connect_messaging_apps": "Conectar aplicaciones de mensajería",
|
||||
"connect_crm_apps": "Conectar aplicaciones CRM",
|
||||
"current_step_of_total": "Paso {{currentStep}} de {{maxSteps}}",
|
||||
|
||||
@@ -679,7 +679,6 @@
|
||||
"profile_picture": "Profileko irudia",
|
||||
"upload": "Kargatu",
|
||||
"add_profile_photo": "Gehitu profileko argazkia",
|
||||
"web3": "Web3",
|
||||
"old_password": "Pasahitz zaharra",
|
||||
"secure_password": "Zure pasahitz berri super segurua",
|
||||
"error_updating_password": "Errorea pasahitza eguneratzean",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"cannot_repackage_codebase": "Vous ne pouvez pas restructurer ou vendre la base de code",
|
||||
"acquire_license": "Obtenez une licence commerciale pour supprimer ces termes en envoyant un e-mail",
|
||||
"terms_summary": "Résumé des conditions",
|
||||
"signing_up_terms": "En vous inscrivant, vous acceptez nos <2>Conditions d'utilisation</2> et notre <3>Politique de confidentialité</3>.",
|
||||
"open_env": "Ouvrez le fichier .env et acceptez notre licence",
|
||||
"env_changed": "J'ai modifié mon fichier .env",
|
||||
"accept_license": "Accepter la licence",
|
||||
@@ -229,6 +230,7 @@
|
||||
"reset_your_password": "Définissez votre nouveau mot de passe avec les instructions envoyées à votre adresse e-mail.",
|
||||
"email_change": "Reconnectez-vous avec votre nouvelle adresse e-mail et votre nouveau mot de passe.",
|
||||
"create_your_account": "Créez votre compte",
|
||||
"create_your_calcom_account": "Créez votre compte Cal.com",
|
||||
"sign_up": "S'inscrire",
|
||||
"youve_been_logged_out": "Vous avez été déconnecté",
|
||||
"hope_to_see_you_soon": "Nous espérons vous revoir bientôt !",
|
||||
@@ -266,6 +268,9 @@
|
||||
"nearly_there_instructions": "Pour finir, une brève description de vous et une photo vous aideront vraiment à obtenir des réservations et à faire savoir aux gens avec qui ils prennent rendez-vous.",
|
||||
"set_availability_instructions": "Définissez des plages de temps pendant lesquelles vous êtes disponible de manière récurrente. Vous pourrez en créer d'autres ultérieurement et les assigner à différents calendriers.",
|
||||
"set_availability": "Définissez vos disponibilités",
|
||||
"set_availbility_description": "Définissez des horaires pour les périodes où vous souhaitez être réservé.",
|
||||
"share_a_link_or_embed": "Partagez un lien ou une intégration",
|
||||
"share_a_link_or_embed_description": "Partagez votre lien ou intégration {{appName}} sur votre site.",
|
||||
"availability_settings": "Paramètres de disponibilité",
|
||||
"continue_without_calendar": "Continuer sans calendrier",
|
||||
"continue_with": "Continuer avec {{appName}}",
|
||||
@@ -800,7 +805,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} fournira un lien de rendez-vous Tandem.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} fournira un lien de rendez-vous vidéo.",
|
||||
"cal_provide_jitsi_meeting_url": "Nous générerons un lien de réunion Jitsi Meet pour vous.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} fournira un lien de rendez-vous vidéo Huddle01 web3.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} fournira un lien de rendez-vous vidéo Huddle01.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} fournira un lien de rendez-vous MS Teams. NOTE : IL FAUT AVOIR UN COMPTE PROFESSIONNEL OU SCOLAIRE",
|
||||
"require_payment": "Exiger un paiement",
|
||||
"you_need_to_add_a_name": "Vous devez ajouter un nom",
|
||||
@@ -904,7 +909,6 @@
|
||||
"no_category_apps_description_analytics": "Ajoutez une application d'analyse pour vos pages de réservation.",
|
||||
"no_category_apps_description_automation": "Ajoutez une application d'automatisation.",
|
||||
"no_category_apps_description_other": "Ajoutez n'importe quel autre type d'application pour faire toutes sortes de choses.",
|
||||
"no_category_apps_description_web3": "Ajoutez une application Web3 pour vos pages de réservation.",
|
||||
"no_category_apps_description_messaging": "Ajoutez une application de messagerie pour configurer des notifications et des rappels personnalisés.",
|
||||
"no_category_apps_description_crm": "Ajoutez une application de CRM pour garder une trace des personnes que vous avez rencontrées.",
|
||||
"installed_app_calendar_description": "Définissez les calendriers pour vérifier les conflits afin d'éviter les doubles réservations.",
|
||||
@@ -913,7 +917,6 @@
|
||||
"installed_app_other_description": "Toutes vos applications installées à partir d'autres catégories.",
|
||||
"installed_app_conferencing_description": "Configurez les applications de conférence à utiliser.",
|
||||
"installed_app_automation_description": "Configurez les applications d'automatisation à utiliser.",
|
||||
"installed_app_web3_description": "Configurez les applications Web3 à utiliser pour vos pages de réservation.",
|
||||
"installed_app_messaging_description": "Configurez les applications de messagerie à utiliser pour configurer des notifications et des rappels personnalisés.",
|
||||
"installed_app_crm_description": "Configurez les applications de CRM à utiliser pour garder une trace des personnes que vous avez rencontrées.",
|
||||
"analytics": "Analytiques",
|
||||
@@ -1036,6 +1039,7 @@
|
||||
"user_impersonation_heading": "Emprunt d'identité",
|
||||
"user_impersonation_description": "Autorisez notre équipe d'assistance à se connecter temporairement à votre place pour nous aider à résoudre les problèmes que vous pourriez nous signaler.",
|
||||
"team_impersonation_description": "Autorisez les propriétaires/administrateurs de votre équipe à se connecter temporairement à votre place.",
|
||||
"cal_signup_description": "Gratuit pour les particuliers. Plans Équipes pour les fonctionnalités collaboratives.",
|
||||
"make_team_private": "Rendre l'équipe privée",
|
||||
"make_team_private_description": "Les membres de votre équipe ne pourront pas voir les autres membres de l'équipe si cette option est activée.",
|
||||
"you_cannot_see_team_members": "Vous ne pouvez pas voir tous les membres d'une équipe privée.",
|
||||
@@ -1303,7 +1307,6 @@
|
||||
"profile_picture": "Photo de profil",
|
||||
"upload": "Télécharger",
|
||||
"add_profile_photo": "Ajouter une photo de profil",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adresse du token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Ancien mot de passe",
|
||||
@@ -1345,7 +1348,6 @@
|
||||
"connect_automation_apps": "Connecter des apps d'automatisation",
|
||||
"connect_analytics_apps": "Connecter des apps d'analytique",
|
||||
"connect_other_apps": "Connecter d'autres applications",
|
||||
"connect_web3_apps": "Connecter des apps Web3",
|
||||
"connect_messaging_apps": "Connecter des apps de messagerie",
|
||||
"connect_crm_apps": "Connecter des apps de CRM",
|
||||
"current_step_of_total": "Étape {{currentStep}} sur {{maxSteps}}",
|
||||
@@ -1528,6 +1530,7 @@
|
||||
"your_org_disbanded_successfully": "Votre organisation a bien été dissoute",
|
||||
"error_creating_team": "Erreur lors de la création de l'équipe",
|
||||
"you": "Vous",
|
||||
"or_continue_with": "Ou continuez avec",
|
||||
"resend_email": "Renvoyer l’e-mail",
|
||||
"member_already_invited": "Le membre a déjà été invité",
|
||||
"already_in_use_error": "Nom d'utilisateur déjà utilisé",
|
||||
@@ -1584,6 +1587,7 @@
|
||||
"enable_apps": "Activer les applications",
|
||||
"enable_apps_description": "Activer les applications que les utilisateurs peuvent intégrer à {{appName}}",
|
||||
"purchase_license": "Acheter une licence",
|
||||
"already_have_account": "J'ai déjà un compte",
|
||||
"already_have_key": "J'ai déjà une clé :",
|
||||
"already_have_key_suggestion": "Veuillez copier votre variable d'environnement CALCOM_LICENSE_KEY existante ici.",
|
||||
"app_is_enabled": "{{appName}} est activé",
|
||||
@@ -2056,7 +2060,9 @@
|
||||
"include_calendar_event": "Inclure l'événement du calendrier",
|
||||
"recently_added": "Ajoutées récemment",
|
||||
"connect_all_calendars": "Connectez vos calendriers",
|
||||
"connect_all_calendars_description": "{{appName}} relève la disponibilité de tous vos calendriers existants.",
|
||||
"workflow_automation": "Automatisation de workflows",
|
||||
"workflow_automation_description": "Personnalisez votre expérience de planification avec les workflows",
|
||||
"scheduling_for_your_team": "Automatisation de workflows",
|
||||
"no_members_found": "Aucun membre trouvé",
|
||||
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} יספק כתובת קישור לפגישת Tandem.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} יספק כתובת קישור לפגישת וידאו.",
|
||||
"cal_provide_jitsi_meeting_url": "אנחנו ניצור עבורך כתובת URL לפגישת Jitsi Meet.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} יספק כתובת קישור לפגישת וידאו web3 ב- Huddle01.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} יספק כתובת קישור לפגישת וידאו ב- Huddle01.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} יספק כתובת קישור לפגישת MS Teams. הערה: חייב להיות חשבון של מקום עבודה או בית ספר",
|
||||
"require_payment": "דרישת תשלום",
|
||||
"you_need_to_add_a_name": "יש להוסיף שם",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "להוסיף אפליקציה לניתוח נתונים עבור דפי ההזמנות שלך",
|
||||
"no_category_apps_description_automation": "להוסיף אפליקציית אוטומציה שברצונך להשתמש בה",
|
||||
"no_category_apps_description_other": "הוסף/הוסיפי אפליקציה מכל סוג אחר לביצוע פעולות שונות",
|
||||
"no_category_apps_description_web3": "הוסף אפליקצית web3 לעמודי תזמון הפגישות שלך",
|
||||
"no_category_apps_description_messaging": "הוסף/הוסיפי אפליקציית מסרים מידיים כדי להגדיר התראות ותזכורות בהתאמה אישית",
|
||||
"no_category_apps_description_crm": "הוסף/י אפליקציית CRM כדי לנהל מעקב אחר האנשים שנפגשת איתם",
|
||||
"installed_app_calendar_description": "הגדר/הגדירח את לוחות השנה כדי לבדוק אם יש התנגשויות על מנת למנוע כפל הזמנות.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "כל האפליקציות המותקנות שלך מקטגוריות אחרות.",
|
||||
"installed_app_conferencing_description": "הגדר/הגדירי את האפליקציות לשיחות ועידה שבהן ברצונך להשתמש",
|
||||
"installed_app_automation_description": "הגדרת אפליקציות האוטומציה שבהן ברצונך להשתמש",
|
||||
"installed_app_web3_description": "הגדר באיזה אפליקציות web3 להשתמש בעמודי תזמון הפגישות שלך",
|
||||
"installed_app_messaging_description": "הגדר/הגדירי את אפליקציות המסרים המידיים שבהן ברצונך להשתמש להגדרת עדכונים ותזכורות",
|
||||
"installed_app_crm_description": "הגדר/הגדירי את אפליקציות ה-CRM שבהן ברצונך להשתמש לניהול מעקב אחר האנשים שנפגשת איתם",
|
||||
"analytics": "ניתוח נתונים",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "תמונת פרופיל",
|
||||
"upload": "העלאה",
|
||||
"add_profile_photo": "הוספת תמונת פרופיל",
|
||||
"web3": "Web3",
|
||||
"token_address": "כתובת הטוקן",
|
||||
"blockchain": "בלוקצ'יין",
|
||||
"old_password": "סיסמה ישנה",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "קישור אפליקציות אוטומציה",
|
||||
"connect_analytics_apps": "קישור אפליקציות ניתוח נתונים",
|
||||
"connect_other_apps": "חיבור אפליקציות אחרות",
|
||||
"connect_web3_apps": "חבר אפליקציות web3",
|
||||
"connect_messaging_apps": "קישור אפליקציות מסרים מידיים",
|
||||
"connect_crm_apps": "קישור אפליקציות CRM",
|
||||
"current_step_of_total": "שלב {{currentStep}} מתוך {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} fornirà un URL di riunione Tandem.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} fornirà un URL di riunione Daily video.",
|
||||
"cal_provide_jitsi_meeting_url": "{{appName}} fornirà un URL di riunione Jitsi Meet.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} fornirà un URL di riunione Huddle01 web3 video.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} fornirà un URL di riunione Huddle01 video.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} fornirà un URL per la riunione MS Teams. NOTA: È NECESSARIO POSSEDERE UN ACCOUNT LAVORATIVO O SCOLASTICO",
|
||||
"require_payment": "Richiedi Pagamento",
|
||||
"you_need_to_add_a_name": "Indica un nome",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Aggiungi un'app di analisi per le tue pagine di prenotazione",
|
||||
"no_category_apps_description_automation": "Aggiungi un'app di automazione da usare",
|
||||
"no_category_apps_description_other": "Aggiungi qualsiasi altra app per fare altre attività",
|
||||
"no_category_apps_description_web3": "Aggiungi un'app Web3 per le tue pagine di prenotazione",
|
||||
"no_category_apps_description_messaging": "Aggiungi un'app di messaggistica per impostare notifiche e promemoria personalizzati",
|
||||
"no_category_apps_description_crm": "Aggiungi un'app CRM per tenere traccia delle persone che hai incontrato",
|
||||
"installed_app_calendar_description": "Imposta uno o più calendari per controllare i conflitti ed evitare doppie prenotazioni.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Tutte le app installate appartenenti ad altre categorie.",
|
||||
"installed_app_conferencing_description": "Scegli quali app di conferenza utilizzare",
|
||||
"installed_app_automation_description": "Imposta quali app di automazione usare",
|
||||
"installed_app_web3_description": "Imposta quali app Web3 usare per le tue pagine di prenotazione",
|
||||
"installed_app_messaging_description": "Scegli le app di messaggistica da usare per impostare notifiche e promemoria personalizzati",
|
||||
"installed_app_crm_description": "Scegli quali app CRM utilizzare per tenere traccia delle persone che hai incontrato",
|
||||
"analytics": "Analisi",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Immagine del profilo",
|
||||
"upload": "Carica",
|
||||
"add_profile_photo": "Aggiungi foto del profilo",
|
||||
"web3": "Web3",
|
||||
"token_address": "Indirizzo token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Vecchia password",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Connetti app di automazione",
|
||||
"connect_analytics_apps": "Connetti app di analisi",
|
||||
"connect_other_apps": "Connetti altre app",
|
||||
"connect_web3_apps": "Connetti app Web3",
|
||||
"connect_messaging_apps": "Connetti app di messaggistica",
|
||||
"connect_crm_apps": "Connetti app CRM",
|
||||
"current_step_of_total": "Passo {{currentStep}} di {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} は Tandem ミーティングの URL を提供する。",
|
||||
"cal_provide_video_meeting_url": "{{appName}} はビデオミーティングの URL を提供する。",
|
||||
"cal_provide_jitsi_meeting_url": "Cal は Jitsi Meet の URL を提供する。",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} は Huddle01 web3 ビデオミーティングの URL を提供する。",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} は Huddle01 ビデオミーティングの URL を提供する。",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} は、MS Teams のミーティングの URL を提供する。注: 職場や学校のアカウントが必要です。",
|
||||
"require_payment": "有料",
|
||||
"you_need_to_add_a_name": "名前を追加する必要があります",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "予約ページに分析アプリを追加する",
|
||||
"no_category_apps_description_automation": "使用する自動化アプリを追加する",
|
||||
"no_category_apps_description_other": "その他のアプリを追加して、様々なことを実現しましょう",
|
||||
"no_category_apps_description_web3": "予約ページに Web3 アプリを追加",
|
||||
"no_category_apps_description_messaging": "カスタム通知とリマインダーを設定するには、メッセージングアプリを追加します",
|
||||
"no_category_apps_description_crm": "会った人を記録するには、CRM アプリを追加します",
|
||||
"installed_app_calendar_description": "ダブルブッキングを防ぐために、カレンダーの重複をチェックするように設定します。",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "その他のカテゴリーからインストールしたすべてのアプリ。",
|
||||
"installed_app_conferencing_description": "どの会議アプリを使用するかを設定します",
|
||||
"installed_app_automation_description": "どの自動化アプリを使用するかを構成する",
|
||||
"installed_app_web3_description": "予約ページでどの Web3 アプリを使用するかを構成します",
|
||||
"installed_app_messaging_description": "カスタム通知とリマインダーの設定にどのメッセージングアプリを使用するかを設定します",
|
||||
"installed_app_crm_description": "会った人を記録するのにどの CRM アプリを使用するかを設定します",
|
||||
"analytics": "分析",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "プロフィール写真",
|
||||
"upload": "アップロード",
|
||||
"add_profile_photo": "プロフィール写真を追加",
|
||||
"web3": "Web3",
|
||||
"token_address": "トークンアドレス",
|
||||
"blockchain": "ブロックチェーン",
|
||||
"old_password": "古いパスワード",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "自動化アプリを接続する",
|
||||
"connect_analytics_apps": "分析アプリを接続する",
|
||||
"connect_other_apps": "その他のアプリを接続",
|
||||
"connect_web3_apps": "Web3 アプリを接続",
|
||||
"connect_messaging_apps": "メッセージングアプリを接続する",
|
||||
"connect_crm_apps": "CRM アプリを接続する",
|
||||
"current_step_of_total": "ステップ {{currentStep}}/{{maxSteps}}",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "예약 페이지에 대한 분석 앱 추가",
|
||||
"no_category_apps_description_automation": "사용할 자동화 앱 추가",
|
||||
"no_category_apps_description_other": "모든 종류의 작업을 수행하는 다른 유형의 앱을 추가합니다",
|
||||
"no_category_apps_description_web3": "예약 페이지에 web3 앱 추가",
|
||||
"no_category_apps_description_messaging": "사용자 정의 알림 및 미리 알림을 설정하려면 메시징 앱을 추가하세요",
|
||||
"no_category_apps_description_crm": "CRM 앱을 추가하여 만났던 사람을 추적하세요",
|
||||
"installed_app_calendar_description": "중복 예약을 방지하기 위해 충돌을 확인하도록 캘린더를 설정합니다.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "기타 카테고리에서 설치된 모든 앱.",
|
||||
"installed_app_conferencing_description": "사용할 회의 앱 구성",
|
||||
"installed_app_automation_description": "사용할 자동화 앱 구성",
|
||||
"installed_app_web3_description": "예약 페이지에 사용할 web3 앱 구성",
|
||||
"installed_app_messaging_description": "사용자 정의 알림 및 미리 알림 설정에 사용할 메시징 앱 구성",
|
||||
"installed_app_crm_description": "만났던 사람을 추적하는 데 사용할 CRM 앱 구성",
|
||||
"analytics": "분석",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "프로필 사진",
|
||||
"upload": "업로드",
|
||||
"add_profile_photo": "프로필 사진 추가",
|
||||
"web3": "Web3",
|
||||
"token_address": "토큰 주소",
|
||||
"blockchain": "블록체인",
|
||||
"old_password": "이전 비밀번호",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "자동화 앱 연결",
|
||||
"connect_analytics_apps": "분석 앱 연결",
|
||||
"connect_other_apps": "기타 앱 연결",
|
||||
"connect_web3_apps": "web3 앱 연결",
|
||||
"connect_messaging_apps": "메시징 앱 연결",
|
||||
"connect_crm_apps": "CRM 앱 연결",
|
||||
"current_step_of_total": "{{maxSteps}} 중 {{currentStep}} 단계",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} zal een Tandem meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} zal een Daily meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_jitsi_meeting_url": "{{appName}} zal een Jitsi Meet meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} zal een Huddle01 web3 meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} zal een Huddle01 meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} geeft een vergaderings-URL voor MS Teams. OPMERKING: MOET EEN WERK- OF SCHOOLACCOUNT HEBBEN",
|
||||
"require_payment": "Betaling vereisen",
|
||||
"you_need_to_add_a_name": "U moet een naam toevoegen",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Voeg een analyse-app toe aan uw boekingspagina's",
|
||||
"no_category_apps_description_automation": "Voeg een automatiseringsapp toe om te gebruiken",
|
||||
"no_category_apps_description_other": "Voeg een ander type app toe om allerlei soorten dingen te doen",
|
||||
"no_category_apps_description_web3": "Voeg een web3-app toe aan uw boekingspagina's",
|
||||
"no_category_apps_description_messaging": "Voeg een berichtenapp toe om aangepaste meldingen en herinneringen in te stellen",
|
||||
"no_category_apps_description_crm": "Voeg een CRM-app toe om bij te houden met wie u heeft ontmoet",
|
||||
"installed_app_calendar_description": "Stel de agenda('s) in om te controleren op conflicten om dubbele boekingen te voorkomen.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Alle geïnstalleerde apps uit andere categorieën.",
|
||||
"installed_app_conferencing_description": "Configureer welke conferentieapps moeten worden gebruikt",
|
||||
"installed_app_automation_description": "Configureer welke automatiseringsapps moeten worden gebruikt",
|
||||
"installed_app_web3_description": "Configureer welke web3-apps moeten worden gebruikt voor uw boekingspagina's",
|
||||
"installed_app_messaging_description": "Configureer welke berichtenapps moeten worden gebruikt voor het instellen van aangepaste meldingen en herinneringen",
|
||||
"installed_app_crm_description": "Configureer welke CRM-apps moeten worden gebruikt voor het bijhouden van wie u hebt ontmoet",
|
||||
"analytics": "Analyse",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profielafbeelding",
|
||||
"upload": "Uploaden",
|
||||
"add_profile_photo": "Profielfoto toevoegen",
|
||||
"web3": "Web3",
|
||||
"token_address": "Tokenadres",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Oude wachtwoord",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Koppel automatiseringsapps",
|
||||
"connect_analytics_apps": "Koppel analyse-apps",
|
||||
"connect_other_apps": "Andere apps koppelen",
|
||||
"connect_web3_apps": "Web3-apps koppelen",
|
||||
"connect_messaging_apps": "Berichtenapps koppelen",
|
||||
"connect_crm_apps": "CRM-apps koppelen",
|
||||
"current_step_of_total": "Stap {{currentStep}} van {{maxSteps}}",
|
||||
|
||||
@@ -670,7 +670,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} vil angi en URL for Tandem-møtet.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} vil angi en videomøte-URL.",
|
||||
"cal_provide_jitsi_meeting_url": "Vi vil generere en Jitsi Meet URL for deg.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} vil angi en Huddle01 web3 videomøte-URL.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} vil angi en Huddle01 videomøte-URL.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} vil angi en URL for MS Teams møte. MERK: MÅ HA EN ARBEIDS- ELLER SKOLEKONTO",
|
||||
"require_payment": "Krev Betaling",
|
||||
"commission_per_transaction": "provisjon per transaksjon",
|
||||
@@ -1112,7 +1112,6 @@
|
||||
"profile_picture": "Profilbilde",
|
||||
"upload": "Last opp",
|
||||
"add_profile_photo": "Legg til profilbilde",
|
||||
"web3": "Web3",
|
||||
"token_address": "Token Adresse",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Gammelt passord",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Dodaj aplikację analityczną do stron rezerwacji",
|
||||
"no_category_apps_description_automation": "Dodaj aplikację automatyzującą, która ma być używana",
|
||||
"no_category_apps_description_other": "Dodaj dowolny typ aplikacji, aby uzyskać dostęp do różnych innych funkcji",
|
||||
"no_category_apps_description_web3": "Dodaj aplikację web3 do stron rezerwacji",
|
||||
"no_category_apps_description_messaging": "Dodaj aplikację do wysyłania wiadomości, aby skonfigurować własne powiadomienia i przypomnienia",
|
||||
"no_category_apps_description_crm": "Dodaj aplikację CRM, aby monitorować to, z kim się spotykasz",
|
||||
"installed_app_calendar_description": "Ustaw kalendarze, aby wykrywać konflikty i unikać podwójnych rezerwacji.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Wszystkie zainstalowane aplikacje z innych kategorii.",
|
||||
"installed_app_conferencing_description": "Skonfiguruj, z których aplikacji konferencyjnych chcesz korzystać",
|
||||
"installed_app_automation_description": "Skonfiguruj, które aplikacje automatyzujące mają być używane",
|
||||
"installed_app_web3_description": "Skonfiguruj aplikacje web3, które mają być używane na stronach rezerwacji",
|
||||
"installed_app_messaging_description": "Skonfiguruj aplikacje, które będą używane do ustawiania niestandardowych powiadomień i przypomnień",
|
||||
"installed_app_crm_description": "Skonfiguruj aplikacje CRM, za pomocą których chcesz monitorować to, z kim się spotykasz",
|
||||
"analytics": "Analityka",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Zdjęcie profilowe",
|
||||
"upload": "Prześlij",
|
||||
"add_profile_photo": "Dodaj zdjęcie profilowe",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adres tokena",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Stare hasło",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Połącz aplikacje automatyzujące",
|
||||
"connect_analytics_apps": "Połącz aplikacje analityczne",
|
||||
"connect_other_apps": "Połącz inne aplikacje",
|
||||
"connect_web3_apps": "Połącz aplikacje web3",
|
||||
"connect_messaging_apps": "Podłącz aplikacje do wysyłania wiadomości",
|
||||
"connect_crm_apps": "Podłącz aplikacje CRM",
|
||||
"current_step_of_total": "Krok {{currentStep}} z {{maxSteps}}",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Adicione um aplicativo de análise às suas páginas de reservas",
|
||||
"no_category_apps_description_automation": "Adicione um aplicativo de automação para usar",
|
||||
"no_category_apps_description_other": "Adicione qualquer outro tipo de aplicativo para fazer todos os tipos de coisas",
|
||||
"no_category_apps_description_web3": "Adicione um aplicativo web3 para suas páginas de reservas",
|
||||
"no_category_apps_description_messaging": "Adicione um aplicativo de mensagem para definir lembretes e notificações personalizados",
|
||||
"no_category_apps_description_crm": "Adicione um aplicativo CRM para acompanhar todos os integrantes das suas reuniões",
|
||||
"installed_app_calendar_description": "Defina o(s) calendário(s) para verificar se há conflitos e evitar reservas duplas.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Todos os seus aplicativos instalados de outras categorias.",
|
||||
"installed_app_conferencing_description": "Configure quais aplicativos de conferência serão usados",
|
||||
"installed_app_automation_description": "Configure quais aplicativos de automação serão usados",
|
||||
"installed_app_web3_description": "Configure quais aplicativos web3 serão usados nas suas páginas de reservas",
|
||||
"installed_app_messaging_description": "Configure quais aplicativos de mensagem usar para definir lembretes e notificações",
|
||||
"installed_app_crm_description": "Configure quais aplicativos CRM usar para acompanhar os integrantes das suas reuniões",
|
||||
"analytics": "Análise",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Imagem do perfil",
|
||||
"upload": "Enviar",
|
||||
"add_profile_photo": "Adicionar foto do perfil",
|
||||
"web3": "Web3",
|
||||
"token_address": "Endereço do Token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Senha antiga",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Conecte aplicativos de automação",
|
||||
"connect_analytics_apps": "Conecte aplicativos de análise",
|
||||
"connect_other_apps": "Conectar outros aplicativos",
|
||||
"connect_web3_apps": "Conecte aplicativos web3",
|
||||
"connect_messaging_apps": "Conectar com aplicativos de mensagem",
|
||||
"connect_crm_apps": "Conecte aplicativos CRM",
|
||||
"current_step_of_total": "Passo {{currentStep}} de {{maxSteps}}",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Adicionar uma aplicação de análise às suas páginas de reservas",
|
||||
"no_category_apps_description_automation": "Adicionar uma aplicação de automatização a utilizar",
|
||||
"no_category_apps_description_other": "Adicione qualquer outro tipo de aplicações para fazer todos os tipos de coisas",
|
||||
"no_category_apps_description_web3": "Adicione uma aplicação web3 às suas páginas de reservas",
|
||||
"no_category_apps_description_messaging": "Adicione uma aplicação de mensagens para configurar notificações e lembretes personalizados",
|
||||
"no_category_apps_description_crm": "Adicione uma aplicação de CRM para manter um registo das pessoas com quem se reuniu",
|
||||
"installed_app_calendar_description": "Defina o(s) calendário(s) para verificar se existem conflitos e assim evitar marcações sobrepostas.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Todas as aplicações instaladas de outras categorias.",
|
||||
"installed_app_conferencing_description": "Configurar as aplicações de conferência a utilizar",
|
||||
"installed_app_automation_description": "Configurar as aplicações de automatização a utilizar",
|
||||
"installed_app_web3_description": "Configure as aplicações web3 a utilizar nas suas páginas de reservas",
|
||||
"installed_app_messaging_description": "Configurar as aplicações de mensagens a utilizar para definir notificações e lembretes personalizados",
|
||||
"installed_app_crm_description": "Configurar as aplicações de CRM a utilizar para manter um registo das pessoas com quem se reuniu",
|
||||
"analytics": "Estatísticas",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Imagem do perfil",
|
||||
"upload": "Carregar",
|
||||
"add_profile_photo": "Adicionar imagem de perfil",
|
||||
"web3": "Web3",
|
||||
"token_address": "Endereço do Token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Palavra-passe antiga",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Associar aplicações de automatização",
|
||||
"connect_analytics_apps": "Associar aplicações de estatística",
|
||||
"connect_other_apps": "Associe outras aplicações",
|
||||
"connect_web3_apps": "Associe aplicações web3",
|
||||
"connect_messaging_apps": "Ligar aplicações de mensagens",
|
||||
"connect_crm_apps": "Ligar aplicações CRM",
|
||||
"current_step_of_total": "Passo {{currentStep}} de {{maxSteps}}",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Adăugați o aplicație de analiză pentru paginile dvs. de rezervări",
|
||||
"no_category_apps_description_automation": "Adăugați o aplicație de automatizare de utilizat",
|
||||
"no_category_apps_description_other": "Adăugați orice alt tip de aplicație pentru a întreprinde diverse acțiuni",
|
||||
"no_category_apps_description_web3": "Adăugați o aplicație Web3 pentru paginile dvs. de rezervări",
|
||||
"no_category_apps_description_messaging": "Adăugați o aplicație de mesagerie pentru a configura notificări și mementouri personalizate",
|
||||
"no_category_apps_description_crm": "Adăugați o aplicație CRM pentru a ține evidența persoanelor cu care v-ați întâlnit",
|
||||
"installed_app_calendar_description": "Pentru a evita rezervările suprapuse, configurează calendarele astfel încât să poată verifica existența conflictelor.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Toate aplicațiile instalate din alte categorii.",
|
||||
"installed_app_conferencing_description": "Configurați ce aplicații de conferințe vor fi utilizate",
|
||||
"installed_app_automation_description": "Configurați care aplicații de automatizare vor fi utilizate",
|
||||
"installed_app_web3_description": "Configurați care aplicații Web3 vor fi utilizate pentru paginile dvs. de rezervări",
|
||||
"installed_app_messaging_description": "Definiți ce aplicații de mesagerie vor fi utilizate pentru configurarea de notificări și mementouri personalizate",
|
||||
"installed_app_crm_description": "Configurați ce aplicații CRM vor fi utilizate pentru a ține evidența persoanelor cu care v-ați întâlnit",
|
||||
"analytics": "Analiză",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Fotografie de profil",
|
||||
"upload": "Încărcare",
|
||||
"add_profile_photo": "Adăugați poză de profil",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adresă token",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Parola veche",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Conectați aplicații de automatizare",
|
||||
"connect_analytics_apps": "Conectați aplicații de analiză",
|
||||
"connect_other_apps": "Conectați alte aplicații",
|
||||
"connect_web3_apps": "Conectați aplicații Web3",
|
||||
"connect_messaging_apps": "Conectați aplicații de mesagerie",
|
||||
"connect_crm_apps": "Conectați aplicații CRM",
|
||||
"current_step_of_total": "Pasul {{currentStep}} din {{maxSteps}}",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Добавьте приложение аналитики для использования на страницах бронирования",
|
||||
"no_category_apps_description_automation": "Добавьте приложение для автоматизации",
|
||||
"no_category_apps_description_other": "Добавляйте всевозможные приложения для решения своих задач",
|
||||
"no_category_apps_description_web3": "Добавьте приложение web3 для использования на страницах бронирования",
|
||||
"no_category_apps_description_messaging": "Добавьте мессенджер, чтобы настроить пользовательские уведомления и напоминания",
|
||||
"no_category_apps_description_crm": "Добавьте приложение CRM, чтобы отслеживать, с кем у вас были встречи",
|
||||
"installed_app_calendar_description": "Настройте проверку календарей на предмет конфликтов для избежания двойного бронирования.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Все установленные приложения из других категорий.",
|
||||
"installed_app_conferencing_description": "Выберите приложения для конференц-связи",
|
||||
"installed_app_automation_description": "Выберите приложения для автоматизации",
|
||||
"installed_app_web3_description": "Выберите приложения web3 для страниц бронирования",
|
||||
"installed_app_messaging_description": "Выберите мессенджеры, с помощью которых вы хотите отправлять пользовательские уведомления и напоминания",
|
||||
"installed_app_crm_description": "Выберите CRM-приложения, в которых вы хотите отслеживать, с кем у вас были встречи",
|
||||
"analytics": "Аналитика",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Фото профиля",
|
||||
"upload": "Загрузить",
|
||||
"add_profile_photo": "Добавить фото профиля",
|
||||
"web3": "Web3",
|
||||
"token_address": "Адрес токена",
|
||||
"blockchain": "Блокчейн",
|
||||
"old_password": "Старый пароль",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Подключить приложения для автоматизации",
|
||||
"connect_analytics_apps": "Подключить приложения аналитики",
|
||||
"connect_other_apps": "Подключить другие приложения",
|
||||
"connect_web3_apps": "Подключить приложения web3",
|
||||
"connect_messaging_apps": "Подключить мессенджеры",
|
||||
"connect_crm_apps": "Подключить CRM-приложения",
|
||||
"current_step_of_total": "Шаг {{currentStep}} из {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} će obezbediti URL Tandem sastanka.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} će obezbediti Daily video meeting URL.",
|
||||
"cal_provide_jitsi_meeting_url": "Mi ćemo generisati Jitsi Meet URL za vas.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} će obezbediti Huddle01 web3 video meeting URL.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} će obezbediti Huddle01 video meeting URL.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} će dostaviti URL adresu MS Teams sastanka. NAPOMENA: NEOPHODAN JE POSLOVNI ILI ŠKOLSKI NALOG",
|
||||
"require_payment": "Obavezno plaćanje",
|
||||
"you_need_to_add_a_name": "Potrebno je da dodate ime",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Dodajte analitičku aplikaciju za vaše stranice za rezervacije",
|
||||
"no_category_apps_description_automation": "Dodajte aplikaciju za automatizaciju za korišćenje",
|
||||
"no_category_apps_description_other": "Dodajte bilo koju vrstu aplikacije da biste uradili svakakve stvari",
|
||||
"no_category_apps_description_web3": "Dodajte web3 aplikaciju na stranice za rezervacije",
|
||||
"no_category_apps_description_messaging": "Dodajte aplikaciju za poruke da biste podesili uobičajena obaveštenja i podsetnike",
|
||||
"no_category_apps_description_crm": "Dodajte aplikaciju za CRM da biste pratili sa kim ste se sreli",
|
||||
"installed_app_calendar_description": "Podesite kalendar(e) da biste proverili da li postoje konflikti i time sprečili dvostruka zakazivanja.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Sve vaše instalirane aplikacije iz drugih kategorija.",
|
||||
"installed_app_conferencing_description": "Konfigurišite koje aplikacije ćete koristiti za konferencije",
|
||||
"installed_app_automation_description": "Konfigurišite koje ćete aplikacije za automatizaciju da koristite",
|
||||
"installed_app_web3_description": "Konfigurišite koje web3 aplikacije će se koristiti za stranice za rezervacije",
|
||||
"installed_app_messaging_description": "Konfigurišite koje aplikacije za poruke ćete koristiti da biste podesili uobičajena obaveštenja i podsetnike",
|
||||
"installed_app_crm_description": "Konfigurišite koje CRM aplikacije ćete koristiti za praćenje sa kim ste se sreli",
|
||||
"analytics": "Analitika",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profilna slika",
|
||||
"upload": "Otpremi",
|
||||
"add_profile_photo": "Dodajte profilnu fotografiju",
|
||||
"web3": "Web3",
|
||||
"token_address": "Adresa tokena",
|
||||
"blockchain": "Lanac blokova",
|
||||
"old_password": "Stara lozinka",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Povežite aplikacije za automatizaciju",
|
||||
"connect_analytics_apps": "Povežite analitičke aplikacije",
|
||||
"connect_other_apps": "Povežite druge aplikacije",
|
||||
"connect_web3_apps": "Povežite web3 aplikacije",
|
||||
"connect_messaging_apps": "Povežite aplikacije za razmenu poruka",
|
||||
"connect_crm_apps": "Povežite CRM aplikacije",
|
||||
"current_step_of_total": "Korak {{currentStep}} od {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} kommer att generera en URL för Tandem-möte.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} kommer att generera en URL för Daily video-möte.",
|
||||
"cal_provide_jitsi_meeting_url": "Vi kommer att generera en Jitsi Meet URL för dig.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} kommer att generera en URL för Huddle01 web3-möte.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} kommer att generera en URL för Huddle01-möte.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} kommer att tillhandahålla en mötes-URL för MS Teams. OBS! MÅSTE HA ETT ARBETS- ELLER SKOLKONTO",
|
||||
"require_payment": "Begär betalning",
|
||||
"you_need_to_add_a_name": "Du måste lägga till ett namn",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Lägg till en analysapp för dina bokningssidor",
|
||||
"no_category_apps_description_automation": "Lägg till en automationsapp att använda",
|
||||
"no_category_apps_description_other": "Lägg till en annan typ av app för att göra alla möjliga saker",
|
||||
"no_category_apps_description_web3": "Lägg till en web3-app för dina bokningssidor",
|
||||
"no_category_apps_description_messaging": "Lägg till en meddelandeapp för att skapa anpassade aviseringar och påminnelser",
|
||||
"no_category_apps_description_crm": "Lägg till en CRM-app för att hålla reda på vilka du har träffat",
|
||||
"installed_app_calendar_description": "Ställ in kalendrar att söka efter konflikter för att undvika dubbelbokningar.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Alla dina installerade appar från andra kategorier.",
|
||||
"installed_app_conferencing_description": "Konfigurera vilka konferensappar som ska användas",
|
||||
"installed_app_automation_description": "Konfigurera vilka automationsappar som ska användas",
|
||||
"installed_app_web3_description": "Konfigurera vilka web3-appar som ska användas för dina bokningssidor",
|
||||
"installed_app_messaging_description": "Konfigurera vilka meddelandeappar som ska användas för att skapa anpassade aviseringar och påminnelser",
|
||||
"installed_app_crm_description": "Konfigurera vilka CRM-appar som ska användas för att hålla reda på vilka du har träffat",
|
||||
"analytics": "Analys",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profilbild",
|
||||
"upload": "Ladda upp",
|
||||
"add_profile_photo": "Lägg till profilfoto",
|
||||
"web3": "Web3",
|
||||
"token_address": "Tokenadress",
|
||||
"blockchain": "Blockkedja",
|
||||
"old_password": "Gammalt lösenord",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Anslut automationsappar",
|
||||
"connect_analytics_apps": "Anslut analysappar",
|
||||
"connect_other_apps": "Anslut andra appar",
|
||||
"connect_web3_apps": "Anslut web3-appar",
|
||||
"connect_messaging_apps": "Anslut meddelandeappar",
|
||||
"connect_crm_apps": "Anslut CRM-appar",
|
||||
"current_step_of_total": "Steg {{currentStep}} av {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}}, bir Tandem toplantı URL'si sağlayacaktır.",
|
||||
"cal_provide_video_meeting_url": "{{appName}}, bir görüntülü toplantı URL'si sağlayacaktır.",
|
||||
"cal_provide_jitsi_meeting_url": "Sizin için bir Jitsi Meet URL'si oluşturacağız.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}}, bir Huddle01 web3 görüntülü toplantı URL'si sağlayacaktır.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}}, bir Huddle01 görüntülü toplantı URL'si sağlayacaktır.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}}, bir MS Teams toplantı URL'si sağlayacaktır. NOT: İŞ VEYA OKUL HESABINIZIN OLMASI GEREKİR",
|
||||
"require_payment": "Ödemeyi Gerekli Kılın",
|
||||
"you_need_to_add_a_name": "Ad eklemeniz gerekiyor",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Rezervasyon sayfalarınız için bir analiz uygulaması ekleyin",
|
||||
"no_category_apps_description_automation": "Kullanılacak otomasyon uygulamasını ekleyin",
|
||||
"no_category_apps_description_other": "Her türlü işlemi yapmak için farklı bir uygulama ekleyin",
|
||||
"no_category_apps_description_web3": "Rezervasyon sayfalarınız için bir web3 uygulaması ekleyin",
|
||||
"no_category_apps_description_messaging": "Özel bildirimler ve hatırlatıcılar ayarlamak için bir mesajlaşma uygulaması ekleyin",
|
||||
"no_category_apps_description_crm": "Tanıştığınız kullanıcıları takip etmek için bir CRM uygulaması ekleyin",
|
||||
"installed_app_calendar_description": "Çifte rezervasyonları önlemek amacıyla çakışmaları kontrol etmek için takvimleri ayarlayın.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Diğer kategorilerdeki tüm yüklü uygulamalarınız.",
|
||||
"installed_app_conferencing_description": "Hangi konferans uygulamalarının kullanılacağını belirleyin",
|
||||
"installed_app_automation_description": "Kullanılacak otomasyon uygulamalarını yapılandırın",
|
||||
"installed_app_web3_description": "Rezervasyon sayfalarınız için hangi web3 uygulamalarının kullanılacağını yapılandırın",
|
||||
"installed_app_messaging_description": "Özel bildirimleri ve hatırlatıcıları ayarlamak için hangi mesajlaşma uygulamalarının kullanılacağını belirleyin",
|
||||
"installed_app_crm_description": "Tanıştığınız kullanıcıları takip etmek için hangi CRM uygulamalarının kullanılacağını belirleyin",
|
||||
"analytics": "Analizler",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Profil resmi",
|
||||
"upload": "Yükle",
|
||||
"add_profile_photo": "Profil fotoğrafı ekleyin",
|
||||
"web3": "Web3",
|
||||
"token_address": "Token Adresi",
|
||||
"blockchain": "Blok zinciri",
|
||||
"old_password": "Eski şifre",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Otomasyon uygulamaları bağlayın",
|
||||
"connect_analytics_apps": "Analiz uygulamaları bağlayın",
|
||||
"connect_other_apps": "Diğer uygulamaları bağlayın",
|
||||
"connect_web3_apps": "Web3 uygulamalarını bağlayın",
|
||||
"connect_messaging_apps": "Mesajlaşma uygulamalarını bağlayın",
|
||||
"connect_crm_apps": "CRM uygulamalarını bağlayın",
|
||||
"current_step_of_total": "{{currentStep}} / {{maxSteps}}. Adım",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} надасть URL-адресу наради Tandem.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} надасть URL-адресу наради Daily.",
|
||||
"cal_provide_jitsi_meeting_url": "Ми згенеруємо URL-адресу Jitsi Meet.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} надасть URL-адресу відеонаради Huddle01 web3.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} надасть URL-адресу відеонаради Huddle01.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} надасть URL-адресу наради в MS Teams. ПРИМІТКА: ПОТРІБЕН РОБОЧИЙ АБО НАВЧАЛЬНИЙ ОБЛІКОВИЙ ЗАПИС",
|
||||
"require_payment": "Вимагати оплату",
|
||||
"you_need_to_add_a_name": "Потрібно додати ім’я",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Вибрати аналітичний додаток для своїх сторінок бронювання",
|
||||
"no_category_apps_description_automation": "Вибрати додаток для автоматизації",
|
||||
"no_category_apps_description_other": "Додавайте інші додатки з різноманітними функціями",
|
||||
"no_category_apps_description_web3": "Вибрати додаток web3 для своїх сторінок бронювання",
|
||||
"no_category_apps_description_messaging": "Додайте застосунок для обміну повідомленнями, щоб налаштувати сповіщення й нагадування",
|
||||
"no_category_apps_description_crm": "Додайте застосунок для CRM, щоб вести облік користувачів, з якими ви зустрічалися",
|
||||
"installed_app_calendar_description": "Налаштуйте календарі, щоб перевіряти, чи немає конфліктів у розкладі, і уникати подвійних бронювань.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Усі ваші встановлені додатки з інших категорій.",
|
||||
"installed_app_conferencing_description": "Укажіть, які застосунки для конференцій використовувати",
|
||||
"installed_app_automation_description": "Указати додатки для автоматизації",
|
||||
"installed_app_web3_description": "Виберіть додатки web3 для своїх сторінок бронювання",
|
||||
"installed_app_messaging_description": "Укажіть, які застосунки для обміну повідомленнями використовувати для налаштування сповіщень і нагадувань",
|
||||
"installed_app_crm_description": "Укажіть, які застосунки для CRM використовувати, щоб вести облік користувачів, з якими ви зустрічались",
|
||||
"analytics": "Аналітика",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Зображення профілю",
|
||||
"upload": "Передати",
|
||||
"add_profile_photo": "Додати фото профілю",
|
||||
"web3": "Web3",
|
||||
"token_address": "Адреса токена",
|
||||
"blockchain": "Блокчейн",
|
||||
"old_password": "Старий пароль",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Підключити додатки для автоматизації",
|
||||
"connect_analytics_apps": "Підключити аналітичні додатки",
|
||||
"connect_other_apps": "Підключити інші додатки",
|
||||
"connect_web3_apps": "Підключити додатки web3",
|
||||
"connect_messaging_apps": "Підключити застосунки для повідомлень",
|
||||
"connect_crm_apps": "Підключити застосунки CRM",
|
||||
"current_step_of_total": "Крок {{currentStep}} із {{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} sẽ cung cấp URL cuộc họp Tandem.",
|
||||
"cal_provide_video_meeting_url": "{{appName}} sẽ cung cấp URL cuộc họp video.",
|
||||
"cal_provide_jitsi_meeting_url": "Chúng tôi sẽ tạo một URL Jitsi Meet cho bạn.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} sẽ cung cấp URL cuộc họp video Huddle01 web3.",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} sẽ cung cấp URL cuộc họp video Huddle01.",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} sẽ cung cấp URL cuộc họp MS Teams. LƯU Ý: PHẢI CÓ TÀI KHOẢN CÔNG VIỆC HOẶC TRƯỜNG HỌC",
|
||||
"require_payment": "Yêu cầu thanh toán",
|
||||
"you_need_to_add_a_name": "Bạn cần thêm vào một tên",
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "Thêm một ứng dụng phân tích cho những trang lịch hẹn của bạn",
|
||||
"no_category_apps_description_automation": "Thêm một ứng dụng tự động hoá để sử dụng",
|
||||
"no_category_apps_description_other": "Thêm loại ứng dụng khác để làm mọi loại công việc",
|
||||
"no_category_apps_description_web3": "Thêm một ứng dụng web3 cho những trang lịch hẹn của bạn",
|
||||
"no_category_apps_description_messaging": "Thêm một ứng dụng nhắn tin để thiết lập thông báo & lời nhắc tuỳ chỉnh",
|
||||
"no_category_apps_description_crm": "Thêm một ứng dụng CRM để theo dõi người mà bạn đã họp cùng",
|
||||
"installed_app_calendar_description": "Đặt (các) lịch làm nhiệm vụ kiểm tra xung đột nhằm ngăn tình trạng đặt lịch trùng.",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "Tất cả những ứng dụng bạn đã cài từ những danh mục khác.",
|
||||
"installed_app_conferencing_description": "Cấu hình ứng dụng hội nghị nào nên dùng",
|
||||
"installed_app_automation_description": "Cấu hình ứng dụng tự động hoá nên dùng",
|
||||
"installed_app_web3_description": "Cấu hình ứng dụng web3 nào cần để sử dụng cho những trang lịch hẹn của bạn",
|
||||
"installed_app_messaging_description": "Cấu hình ứng dụng nhắn tin nào cần dùng để thiết lập thông báo & lời nhắc tuỳ chỉnh",
|
||||
"installed_app_crm_description": "Cấu hình ứng dụng CRM nào cần dùng để theo dõi những người mà bạn đã họp cùng",
|
||||
"analytics": "Phân tích",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "Ảnh hồ sơ",
|
||||
"upload": "Tải lên",
|
||||
"add_profile_photo": "Thêm ảnh hồ sơ",
|
||||
"web3": "Web3",
|
||||
"token_address": "Địa chỉ token",
|
||||
"blockchain": "Chuỗi khối",
|
||||
"old_password": "Mật khẩu cũ",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "Kết nối các ứng dụng tự động hoá",
|
||||
"connect_analytics_apps": "Kết nối các ứng dụng phân tích",
|
||||
"connect_other_apps": "Kết nối các ứng dụng khác",
|
||||
"connect_web3_apps": "Kết nối các ứng dụng web3",
|
||||
"connect_messaging_apps": "Kết nối các ứng dụng nhắn tin",
|
||||
"connect_crm_apps": "Kết nối các ứng dụng CRM",
|
||||
"current_step_of_total": "Bước {{currentStep}}/{{maxSteps}}",
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
"cal_provide_tandem_meeting_url": "{{appName}} 将提供 Tandem 会议链接",
|
||||
"cal_provide_video_meeting_url": "{{appName}}将提供 Daily 视频会议链接",
|
||||
"cal_provide_jitsi_meeting_url": "{{appName}} 将提供 Jitsi Meet 视频会议链接",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} 将提供 Huddle01 web3 视频会议链接",
|
||||
"cal_provide_huddle01_meeting_url": "{{appName}} 将提供 Huddle01 视频会议链接",
|
||||
"cal_provide_teams_meeting_url": "{{appName}} 将提供 MS Teams 会议 URL。注意: 须拥有工作或学校帐户",
|
||||
"require_payment": "需要付款",
|
||||
"you_need_to_add_a_name": "您需要添加名称",
|
||||
@@ -905,7 +905,6 @@
|
||||
"no_category_apps_description_analytics": "为您的预约页面添加分析应用",
|
||||
"no_category_apps_description_automation": "添加要使用的自动化应用",
|
||||
"no_category_apps_description_other": "添加任何其他类型的应用以执行各种操作",
|
||||
"no_category_apps_description_web3": "为您的预约页面添加 web3 应用",
|
||||
"no_category_apps_description_messaging": "添加一个消息应用,以设置自定义通知和提醒",
|
||||
"no_category_apps_description_crm": "添加一个 CRM 应用,以记录您见过的人",
|
||||
"installed_app_calendar_description": "设置日历以检查冲突,防止重复预约。",
|
||||
@@ -914,7 +913,6 @@
|
||||
"installed_app_other_description": "所有属于其他类别的已安装应用。",
|
||||
"installed_app_conferencing_description": "配置要使用的会议应用",
|
||||
"installed_app_automation_description": "配置要使用的自动化应用",
|
||||
"installed_app_web3_description": "配置将用于您的预约页面的 web3 应用",
|
||||
"installed_app_messaging_description": "配置用于设置自定义通知和提醒的消息应用",
|
||||
"installed_app_crm_description": "配置用于记录您见过的人的 CRM 应用",
|
||||
"analytics": "分析",
|
||||
@@ -1305,7 +1303,6 @@
|
||||
"profile_picture": "个人资料图片",
|
||||
"upload": "上传",
|
||||
"add_profile_photo": "添加个人资料照片",
|
||||
"web3": "Web3",
|
||||
"token_address": "代币地址",
|
||||
"blockchain": "区块链",
|
||||
"old_password": "旧密码",
|
||||
@@ -1347,7 +1344,6 @@
|
||||
"connect_automation_apps": "连接自动化应用",
|
||||
"connect_analytics_apps": "连接分析应用",
|
||||
"connect_other_apps": "连接其他应用",
|
||||
"connect_web3_apps": "连接 web3 应用",
|
||||
"connect_messaging_apps": "连接消息应用",
|
||||
"connect_crm_apps": "连接 CRM 应用",
|
||||
"current_step_of_total": "第 {{currentStep}} 步,共 {{maxSteps}} 步",
|
||||
|
||||
@@ -904,7 +904,6 @@
|
||||
"no_category_apps_description_analytics": "為您的預約頁面加入分析應用程式",
|
||||
"no_category_apps_description_automation": "加入自動化應用程式來使用",
|
||||
"no_category_apps_description_other": "新增任何其他類型的應用程式以完成各種操作",
|
||||
"no_category_apps_description_web3": "為您的預約頁面加入 web3 應用程式",
|
||||
"no_category_apps_description_messaging": "新增傳訊應用程式,以設定自訂通知和提醒",
|
||||
"no_category_apps_description_crm": "新增 CRM 應用程式,追蹤您已見過面的人",
|
||||
"installed_app_calendar_description": "設定行事曆來檢查衝突,以避免重複預約。",
|
||||
@@ -913,7 +912,6 @@
|
||||
"installed_app_other_description": "其他類別的所有已安裝應用程式。",
|
||||
"installed_app_conferencing_description": "設定要使用哪些會議應用程式",
|
||||
"installed_app_automation_description": "設定要使用哪些自動化應用程式",
|
||||
"installed_app_web3_description": "設定要為您的預約頁面使用哪些 web3 應用程式",
|
||||
"installed_app_messaging_description": "設定要使用哪些傳訊應用程式來設定自訂通知與提醒",
|
||||
"installed_app_crm_description": "設定要使用哪些 CRM 應用程式來追蹤您已見過面的人",
|
||||
"analytics": "分析",
|
||||
@@ -1304,7 +1302,6 @@
|
||||
"profile_picture": "個人資料圖片",
|
||||
"upload": "上傳",
|
||||
"add_profile_photo": "新增個人資料相片",
|
||||
"web3": "Web3",
|
||||
"token_address": "代幣地址",
|
||||
"blockchain": "區塊鏈",
|
||||
"old_password": "舊密碼",
|
||||
@@ -1346,7 +1343,6 @@
|
||||
"connect_automation_apps": "連至自動化應用程式",
|
||||
"connect_analytics_apps": "連至分析應用程式",
|
||||
"connect_other_apps": "連接其他應用程式",
|
||||
"connect_web3_apps": "連接 web3 應用程式",
|
||||
"connect_messaging_apps": "連接傳訊應用程式",
|
||||
"connect_crm_apps": "連接 CRM 應用程式",
|
||||
"current_step_of_total": "第 {{currentStep}} 步,共 {{maxSteps}} 步",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type z from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
@@ -87,7 +88,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("Alby: Payment could not be created", bookingId, JSON.stringify(error));
|
||||
log.error("Alby: Payment could not be created", bookingId, safeStringify(error));
|
||||
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import type CalendarService from "@calcom/lib/CalendarService";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type {
|
||||
Calendar,
|
||||
@@ -106,6 +107,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
});
|
||||
myGoogleAuth.setCredentials(googleCredentials);
|
||||
} catch (err) {
|
||||
this.log.error("Error Refreshing Google Token", safeStringify(err));
|
||||
let message;
|
||||
if (err instanceof Error) message = err.message;
|
||||
else message = String(err);
|
||||
@@ -251,7 +253,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
iCalUID: event.data.iCalUID,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("There was an error contacting google calendar service: ", error);
|
||||
this.log.error(
|
||||
"There was an error creating event in google calendar: ",
|
||||
safeStringify({ error, selectedCalendar, credentialId })
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -332,7 +337,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
return evt?.data;
|
||||
} catch (error) {
|
||||
console.error("There was an error contacting google calendar service: ", error);
|
||||
this.log.error(
|
||||
"There was an error updating event in google calendar: ",
|
||||
safeStringify({ error, event, uid })
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -354,6 +362,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
});
|
||||
return event?.data;
|
||||
} catch (error) {
|
||||
this.log.error(
|
||||
"There was an error deleting event from google calendar: ",
|
||||
safeStringify({ error, event, externalCalendarId })
|
||||
);
|
||||
const err = error as GoogleCalError;
|
||||
/**
|
||||
* 410 is when an event is already deleted on the Google cal before on cal.com
|
||||
@@ -502,7 +514,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
return busyData;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("There was an error contacting google calendar service: ", error);
|
||||
this.log.error(
|
||||
"There was an error getting availability from google calendar: ",
|
||||
safeStringify({ error, selectedCalendars })
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -524,7 +539,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
} satisfies IntegrationCalendar)
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.error("There was an error contacting google calendar service: ", error);
|
||||
this.log.error("There was an error getting calendars: ", safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
@@ -91,7 +92,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("Paypal: Payment could not be created for bookingId", bookingId);
|
||||
log.error("Paypal: Payment could not be created for bookingId", bookingId, safeStringify(error));
|
||||
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +171,11 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("Paypal: Payment method could not be collected for bookingId", bookingId);
|
||||
log.error(
|
||||
"Paypal: Payment method could not be collected for bookingId",
|
||||
bookingId,
|
||||
safeStringify(error)
|
||||
);
|
||||
throw new Error("Paypal: Payment method could not be collected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sendAwaitingPaymentEmail } from "@calcom/emails";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
@@ -132,8 +133,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(`Payment could not be created for bookingId ${bookingId}`, error);
|
||||
log.error("Stripe: Payment could not be created", bookingId, JSON.stringify(error));
|
||||
log.error("Stripe: Payment could not be created", bookingId, safeStringify(error));
|
||||
throw new Error("payment_not_created_error");
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
log.error(
|
||||
"Stripe: Payment method could not be collected for bookingId",
|
||||
bookingId,
|
||||
JSON.stringify(error)
|
||||
safeStringify(error)
|
||||
);
|
||||
throw new Error("Stripe: Payment method could not be collected");
|
||||
}
|
||||
@@ -286,7 +286,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("Stripe: Could not charge card for payment", _bookingId, JSON.stringify(error));
|
||||
log.error("Stripe: Could not charge card for payment", _bookingId, safeStringify(error));
|
||||
throw new Error(ErrorCode.ChargeCardFailure);
|
||||
}
|
||||
}
|
||||
@@ -378,7 +378,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
await this.stripe.paymentIntents.cancel(payment.externalId, { stripeAccount });
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.error("Stripe: Unable to delete Payment in stripe of paymentId", paymentId, JSON.stringify(e));
|
||||
log.error("Stripe: Unable to delete Payment in stripe of paymentId", paymentId, safeStringify(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,8 @@ export default class EventManager {
|
||||
event: CalendarEvent,
|
||||
rescheduleUid: string,
|
||||
newBookingId?: number,
|
||||
changedOrganizer?: boolean
|
||||
changedOrganizer?: boolean,
|
||||
newDestinationCalendar?: DestinationCalendar[] | null
|
||||
): Promise<CreateUpdateResult> {
|
||||
const originalEvt = processLocation(event);
|
||||
const evt = cloneDeep(originalEvt);
|
||||
@@ -371,6 +372,7 @@ export default class EventManager {
|
||||
}
|
||||
|
||||
const results: Array<EventResult<Event>> = [];
|
||||
const bookingReferenceChangedOrganizer: Array<PartialReference> = [];
|
||||
|
||||
if (evt.requiresConfirmation) {
|
||||
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
|
||||
@@ -380,7 +382,13 @@ export default class EventManager {
|
||||
if (changedOrganizer) {
|
||||
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
|
||||
await this.deleteEventsAndMeetings({ booking, event });
|
||||
// New event is created in handleNewBooking
|
||||
|
||||
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
|
||||
|
||||
const newEvent = { ...evt, destinationCalendar: newDestinationCalendar };
|
||||
const createdEvent = await this.create(newEvent);
|
||||
results.push(...createdEvent.results);
|
||||
bookingReferenceChangedOrganizer.push(...createdEvent.referencesToCreate);
|
||||
} else {
|
||||
// If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time.
|
||||
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
|
||||
@@ -427,7 +435,7 @@ export default class EventManager {
|
||||
|
||||
return {
|
||||
results,
|
||||
referencesToCreate: [...booking.references],
|
||||
referencesToCreate: changedOrganizer ? bookingReferenceChangedOrganizer : [...booking.references],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ const updateMeeting = async (
|
||||
if (!updatedMeeting) {
|
||||
log.error(
|
||||
"updateMeeting failed",
|
||||
JSON.stringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
|
||||
safeStringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
|
||||
);
|
||||
return {
|
||||
appName: credential.appId || "",
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function DisconnectIntegration({
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ export const BookEventFormChild = ({
|
||||
hashedLink,
|
||||
};
|
||||
|
||||
if (eventQuery.data?.recurringEvent?.freq && recurringEventCount) {
|
||||
if (eventQuery.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
|
||||
createRecurringBookingMutation.mutate(
|
||||
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -110,6 +111,7 @@ export const EventMetaBlock = ({
|
||||
*/
|
||||
export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: EventDetailsProps) => {
|
||||
const { t } = useLocale();
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -144,7 +146,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
||||
);
|
||||
|
||||
case EventDetailBlocks.OCCURENCES:
|
||||
if (!event.recurringEvent) return null;
|
||||
if (!event.recurringEvent || rescheduleUid) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock key={block} icon={RefreshCcw}>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
bookingCreateSchemaLegacyPropsForApi,
|
||||
bookingCreateBodySchemaForApi,
|
||||
extendedBookingCreateBody,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import getBookingResponsesSchema from "./getBookingResponsesSchema";
|
||||
import type { getEventTypesFromDB } from "./handleNewBooking";
|
||||
|
||||
const getBookingDataSchema = (
|
||||
rescheduleUid: string | undefined,
|
||||
isNotAnApiCall: boolean,
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>
|
||||
) => {
|
||||
const responsesSchema = getBookingResponsesSchema({
|
||||
eventType: {
|
||||
bookingFields: eventType.bookingFields,
|
||||
},
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
const bookingDataSchema = isNotAnApiCall
|
||||
? extendedBookingCreateBody.merge(
|
||||
z.object({
|
||||
responses: responsesSchema,
|
||||
})
|
||||
)
|
||||
: bookingCreateBodySchemaForApi
|
||||
.merge(
|
||||
z.object({
|
||||
responses: responsesSchema.optional(),
|
||||
})
|
||||
)
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.responses && val.customInputs) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Don't use both customInputs and responses. `customInputs` is only there for legacy support.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape);
|
||||
|
||||
if (val.responses) {
|
||||
const unwantedProps: string[] = [];
|
||||
legacyProps.forEach((legacyProp) => {
|
||||
if (typeof val[legacyProp as keyof typeof val] !== "undefined") {
|
||||
console.error(
|
||||
`Deprecated: Unexpected falsy value for: ${unwantedProps.join(
|
||||
","
|
||||
)}. They can't be used with \`responses\`. This will become a 400 error in the future.`
|
||||
);
|
||||
}
|
||||
if (val[legacyProp as keyof typeof val]) {
|
||||
unwantedProps.push(legacyProp);
|
||||
}
|
||||
});
|
||||
if (unwantedProps.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (val.customInputs) {
|
||||
const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val);
|
||||
if (!success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return bookingDataSchema;
|
||||
};
|
||||
|
||||
export default getBookingDataSchema;
|
||||
@@ -3,6 +3,7 @@ import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWe
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -65,6 +66,6 @@ export async function handleBookingRequested(args: {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
log.error(error);
|
||||
log.error("Error in handleBookingRequested", safeStringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,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 { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -65,7 +66,7 @@ export async function handleConfirmation(args: {
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${user.username} failed`, JSON.stringify({ error, results }));
|
||||
log.error(`Booking ${user.username} failed`, safeStringify({ error, results }));
|
||||
} else {
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
|
||||
@@ -77,11 +77,9 @@ import type { BookingReference } from "@calcom/prisma/client";
|
||||
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import {
|
||||
bookingCreateBodySchemaForApi,
|
||||
bookingCreateSchemaLegacyPropsForApi,
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
extendedBookingCreateBody,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
@@ -96,7 +94,7 @@ import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
|
||||
import getBookingResponsesSchema from "./getBookingResponsesSchema";
|
||||
import getBookingDataSchema from "./getBookingDataSchema";
|
||||
|
||||
const translator = short();
|
||||
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||
@@ -104,6 +102,14 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
type BufferedBusyTimes = BufferedBusyTime[];
|
||||
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
|
||||
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
|
||||
export type NewBookingEventType =
|
||||
| Awaited<ReturnType<typeof getDefaultEvent>>
|
||||
| Awaited<ReturnType<typeof getEventTypesFromDB>>;
|
||||
|
||||
// Work with Typescript to require reqBody.end
|
||||
type ReqBodyWithoutEnd = z.infer<ReturnType<typeof getBookingDataSchema>>;
|
||||
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
|
||||
|
||||
interface IEventTypePaymentCredentialType {
|
||||
appId: EventTypeAppsList;
|
||||
@@ -244,7 +250,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType,
|
||||
return false;
|
||||
}
|
||||
|
||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
const eventType = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
@@ -363,6 +369,53 @@ type IsFixedAwareUser = User & {
|
||||
organization: { slug: string };
|
||||
};
|
||||
|
||||
const loadUsers = async (
|
||||
eventType: NewBookingEventType,
|
||||
dynamicUserList: string[],
|
||||
reqHeadersHost: string | undefined
|
||||
) => {
|
||||
try {
|
||||
if (!eventType.id) {
|
||||
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
|
||||
throw new Error("dynamicUserList is not properly defined or empty.");
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: { in: dynamicUserList },
|
||||
organization: userOrgQuery(reqHeadersHost ? reqHeadersHost.replace(/^https?:\/\//, "") : ""),
|
||||
},
|
||||
select: {
|
||||
...userSelect.select,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return users;
|
||||
}
|
||||
const hosts = eventType.hosts || [];
|
||||
|
||||
if (!Array.isArray(hosts)) {
|
||||
throw new Error("eventType.hosts is not properly defined.");
|
||||
}
|
||||
|
||||
const users = hosts.map(({ user, isFixed }) => ({
|
||||
...user,
|
||||
isFixed,
|
||||
}));
|
||||
|
||||
return users.length ? users : eventType.users;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
throw new HttpError({ statusCode: 500, message: "Unable to load users" });
|
||||
}
|
||||
};
|
||||
|
||||
async function ensureAvailableUsers(
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
users: IsFixedAwareUser[];
|
||||
@@ -506,73 +559,10 @@ async function getBookingData({
|
||||
isNotAnApiCall: boolean;
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
|
||||
}) {
|
||||
const responsesSchema = getBookingResponsesSchema({
|
||||
eventType: {
|
||||
bookingFields: eventType.bookingFields,
|
||||
},
|
||||
view: req.body.rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
const bookingDataSchema = isNotAnApiCall
|
||||
? extendedBookingCreateBody.merge(
|
||||
z.object({
|
||||
responses: responsesSchema,
|
||||
})
|
||||
)
|
||||
: bookingCreateBodySchemaForApi
|
||||
.merge(
|
||||
z.object({
|
||||
responses: responsesSchema.optional(),
|
||||
})
|
||||
)
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.responses && val.customInputs) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Don't use both customInputs and responses. `customInputs` is only there for legacy support.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape);
|
||||
|
||||
if (val.responses) {
|
||||
const unwantedProps: string[] = [];
|
||||
legacyProps.forEach((legacyProp) => {
|
||||
if (typeof val[legacyProp as keyof typeof val] !== "undefined") {
|
||||
console.error(
|
||||
`Deprecated: Unexpected falsy value for: ${unwantedProps.join(
|
||||
","
|
||||
)}. They can't be used with \`responses\`. This will become a 400 error in the future.`
|
||||
);
|
||||
}
|
||||
if (val[legacyProp as keyof typeof val]) {
|
||||
unwantedProps.push(legacyProp);
|
||||
}
|
||||
});
|
||||
if (unwantedProps.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (val.customInputs) {
|
||||
const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val);
|
||||
if (!success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
const bookingDataSchema = getBookingDataSchema(req.body?.rescheduleUid, isNotAnApiCall, eventType);
|
||||
|
||||
const reqBody = await bookingDataSchema.parseAsync(req.body);
|
||||
|
||||
// Work with Typescript to require reqBody.end
|
||||
type ReqBodyWithoutEnd = z.infer<typeof bookingDataSchema>;
|
||||
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
|
||||
|
||||
const reqBodyWithEnd = (reqBody: ReqBodyWithoutEnd): reqBody is ReqBodyWithEnd => {
|
||||
// Use the event length to auto-set the event end time.
|
||||
if (!Object.prototype.hasOwnProperty.call(reqBody, "end")) {
|
||||
@@ -626,6 +616,181 @@ async function getBookingData({
|
||||
}
|
||||
}
|
||||
|
||||
async function createBooking({
|
||||
originalRescheduledBooking,
|
||||
evt,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
reqBodyUser,
|
||||
reqBodyMetadata,
|
||||
reqBodyRecurringEventId,
|
||||
uid,
|
||||
responses,
|
||||
isConfirmedByDefault,
|
||||
smsReminderNumber,
|
||||
organizerUser,
|
||||
rescheduleReason,
|
||||
eventType,
|
||||
bookerEmail,
|
||||
paymentAppData,
|
||||
changedOrganizer,
|
||||
}: {
|
||||
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
|
||||
evt: CalendarEvent;
|
||||
eventType: NewBookingEventType;
|
||||
eventTypeId: Awaited<ReturnType<typeof getBookingData>>["eventTypeId"];
|
||||
eventTypeSlug: Awaited<ReturnType<typeof getBookingData>>["eventTypeSlug"];
|
||||
reqBodyUser: ReqBodyWithEnd["user"];
|
||||
reqBodyMetadata: ReqBodyWithEnd["metadata"];
|
||||
reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"];
|
||||
uid: short.SUUID;
|
||||
responses: ReqBodyWithEnd["responses"] | null;
|
||||
isConfirmedByDefault: ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
|
||||
smsReminderNumber: Awaited<ReturnType<typeof getBookingData>>["smsReminderNumber"];
|
||||
organizerUser: Awaited<ReturnType<typeof loadUsers>>[number] & {
|
||||
isFixed?: boolean;
|
||||
metadata?: Prisma.JsonValue;
|
||||
};
|
||||
rescheduleReason: Awaited<ReturnType<typeof getBookingData>>["rescheduleReason"];
|
||||
bookerEmail: Awaited<ReturnType<typeof getBookingData>>["email"];
|
||||
paymentAppData: ReturnType<typeof getPaymentAppData>;
|
||||
changedOrganizer: boolean;
|
||||
}) {
|
||||
if (originalRescheduledBooking) {
|
||||
evt.title = originalRescheduledBooking?.title || evt.title;
|
||||
evt.description = originalRescheduledBooking?.description || evt.description;
|
||||
evt.location = originalRescheduledBooking?.location || evt.location;
|
||||
evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location;
|
||||
}
|
||||
|
||||
const eventTypeRel = !eventTypeId
|
||||
? {}
|
||||
: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
};
|
||||
|
||||
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
|
||||
const dynamicGroupSlugRef = !eventTypeId ? (reqBodyUser as string).toLowerCase() : null;
|
||||
|
||||
const attendeesData = evt.attendees.map((attendee) => {
|
||||
//if attendee is team member, it should fetch their locale not booker's locale
|
||||
//perhaps make email fetch request to see if his locale is stored, else
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
locale: attendee.language.locale,
|
||||
};
|
||||
});
|
||||
|
||||
if (evt.team?.members) {
|
||||
attendeesData.push(
|
||||
...evt.team.members.map((member) => ({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
timeZone: member.timeZone,
|
||||
locale: member.language.locale,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const newBookingData: Prisma.BookingCreateInput = {
|
||||
uid,
|
||||
responses: responses === null ? Prisma.JsonNull : responses,
|
||||
title: evt.title,
|
||||
startTime: dayjs.utc(evt.startTime).toDate(),
|
||||
endTime: dayjs.utc(evt.endTime).toDate(),
|
||||
description: evt.additionalNotes,
|
||||
customInputs: isPrismaObjOrUndefined(evt.customInputs),
|
||||
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
|
||||
location: evt.location,
|
||||
eventType: eventTypeRel,
|
||||
smsReminderNumber,
|
||||
metadata: reqBodyMetadata,
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendeesData,
|
||||
},
|
||||
},
|
||||
dynamicEventSlugRef,
|
||||
dynamicGroupSlugRef,
|
||||
user: {
|
||||
connect: {
|
||||
id: organizerUser.id,
|
||||
},
|
||||
},
|
||||
destinationCalendar:
|
||||
evt.destinationCalendar && evt.destinationCalendar.length > 0
|
||||
? {
|
||||
connect: { id: evt.destinationCalendar[0].id },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (reqBodyRecurringEventId) {
|
||||
newBookingData.recurringEventId = reqBodyRecurringEventId;
|
||||
}
|
||||
if (originalRescheduledBooking) {
|
||||
newBookingData.metadata = {
|
||||
...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata),
|
||||
};
|
||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||
if (originalRescheduledBooking.uid) {
|
||||
newBookingData.cancellationReason = rescheduleReason;
|
||||
}
|
||||
if (newBookingData.attendees?.createMany?.data) {
|
||||
// Reschedule logic with booking with seats
|
||||
if (eventType?.seatsPerTimeSlot && bookerEmail) {
|
||||
newBookingData.attendees.createMany.data = attendeesData.filter(
|
||||
(attendee) => attendee.email === bookerEmail
|
||||
);
|
||||
}
|
||||
}
|
||||
if (originalRescheduledBooking.recurringEventId) {
|
||||
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
|
||||
}
|
||||
}
|
||||
const createBookingObj = {
|
||||
include: {
|
||||
user: {
|
||||
select: { email: true, name: true, timeZone: true, username: true },
|
||||
},
|
||||
attendees: true,
|
||||
payment: true,
|
||||
references: true,
|
||||
},
|
||||
data: newBookingData,
|
||||
};
|
||||
|
||||
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
|
||||
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
|
||||
|
||||
if (bookingPayment) {
|
||||
createBookingObj.data.payment = {
|
||||
connect: { id: bookingPayment.id },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) {
|
||||
/* Validate if there is any payment app credential for this user */
|
||||
await prisma.credential.findFirstOrThrow({
|
||||
where: {
|
||||
appId: paymentAppData.appId,
|
||||
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.booking.create(createBookingObj);
|
||||
}
|
||||
|
||||
function getCustomInputsResponses(
|
||||
reqBody: {
|
||||
responses?: Record<string, object>;
|
||||
@@ -781,54 +946,11 @@ async function handler(
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
if (!eventTypeId) {
|
||||
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
|
||||
throw new Error("dynamicUserList is not properly defined or empty.");
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: { in: dynamicUserList },
|
||||
organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""),
|
||||
},
|
||||
select: {
|
||||
...userSelect.select,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return users;
|
||||
} else {
|
||||
const hosts = eventType.hosts || [];
|
||||
|
||||
if (!Array.isArray(hosts)) {
|
||||
throw new Error("eventType.hosts is not properly defined.");
|
||||
}
|
||||
|
||||
const users = hosts.map(({ user, isFixed }) => ({
|
||||
...user,
|
||||
isFixed,
|
||||
}));
|
||||
|
||||
return users.length ? users : eventType.users;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
throw new HttpError({ statusCode: 500, message: "Unable to load users" });
|
||||
}
|
||||
};
|
||||
// loadUsers allows type inferring
|
||||
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & {
|
||||
isFixed?: boolean;
|
||||
metadata?: Prisma.JsonValue;
|
||||
})[] = await loadUsers();
|
||||
})[] = await loadUsers(eventType, dynamicUserList, req.headers.host);
|
||||
|
||||
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
|
||||
if (!isDynamicAllowed && !eventTypeId) {
|
||||
@@ -1923,147 +2045,9 @@ async function handler(
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
originalRescheduledBooking.userId !== evt.organizer.id;
|
||||
|
||||
async function createBooking() {
|
||||
if (originalRescheduledBooking) {
|
||||
evt.title = originalRescheduledBooking?.title || evt.title;
|
||||
evt.description = originalRescheduledBooking?.description || evt.description;
|
||||
evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location;
|
||||
}
|
||||
|
||||
const eventTypeRel = !eventTypeId
|
||||
? {}
|
||||
: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
};
|
||||
|
||||
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
|
||||
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
|
||||
|
||||
const attendeesData = evt.attendees.map((attendee) => {
|
||||
//if attendee is team member, it should fetch their locale not booker's locale
|
||||
//perhaps make email fetch request to see if his locale is stored, else
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
locale: attendee.language.locale,
|
||||
};
|
||||
});
|
||||
|
||||
if (evt.team?.members) {
|
||||
attendeesData.push(
|
||||
...evt.team.members.map((member) => ({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
timeZone: member.timeZone,
|
||||
locale: member.language.locale,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const newBookingData: Prisma.BookingCreateInput = {
|
||||
uid,
|
||||
responses: responses === null ? Prisma.JsonNull : responses,
|
||||
title: evt.title,
|
||||
startTime: dayjs.utc(evt.startTime).toDate(),
|
||||
endTime: dayjs.utc(evt.endTime).toDate(),
|
||||
description: evt.additionalNotes,
|
||||
customInputs: isPrismaObjOrUndefined(evt.customInputs),
|
||||
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
|
||||
location: evt.location,
|
||||
eventType: eventTypeRel,
|
||||
smsReminderNumber,
|
||||
metadata: reqBody.metadata,
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendeesData,
|
||||
},
|
||||
},
|
||||
dynamicEventSlugRef,
|
||||
dynamicGroupSlugRef,
|
||||
user: {
|
||||
connect: {
|
||||
id: organizerUser.id,
|
||||
},
|
||||
},
|
||||
destinationCalendar:
|
||||
evt.destinationCalendar && evt.destinationCalendar.length > 0
|
||||
? {
|
||||
connect: { id: evt.destinationCalendar[0].id },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (reqBody.recurringEventId) {
|
||||
newBookingData.recurringEventId = reqBody.recurringEventId;
|
||||
}
|
||||
if (originalRescheduledBooking) {
|
||||
newBookingData.metadata = {
|
||||
...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata),
|
||||
};
|
||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||
if (originalRescheduledBooking.uid) {
|
||||
newBookingData.cancellationReason = rescheduleReason;
|
||||
}
|
||||
if (newBookingData.attendees?.createMany?.data) {
|
||||
// Reschedule logic with booking with seats
|
||||
if (eventType?.seatsPerTimeSlot && bookerEmail) {
|
||||
newBookingData.attendees.createMany.data = attendeesData.filter(
|
||||
(attendee) => attendee.email === bookerEmail
|
||||
);
|
||||
}
|
||||
}
|
||||
if (originalRescheduledBooking.recurringEventId) {
|
||||
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
|
||||
}
|
||||
}
|
||||
const createBookingObj = {
|
||||
include: {
|
||||
user: {
|
||||
select: { email: true, name: true, timeZone: true, username: true },
|
||||
},
|
||||
attendees: true,
|
||||
payment: true,
|
||||
references: true,
|
||||
},
|
||||
data: newBookingData,
|
||||
};
|
||||
|
||||
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
|
||||
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
|
||||
|
||||
if (bookingPayment) {
|
||||
createBookingObj.data.payment = {
|
||||
connect: { id: bookingPayment.id },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) {
|
||||
/* Validate if there is any payment app credential for this user */
|
||||
await prisma.credential.findFirstOrThrow({
|
||||
where: {
|
||||
appId: paymentAppData.appId,
|
||||
...(paymentAppData.credentialId
|
||||
? { id: paymentAppData.credentialId }
|
||||
: { userId: organizerUser.id }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.booking.create(createBookingObj);
|
||||
}
|
||||
|
||||
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
|
||||
let referencesToCreate: PartialReference[] = [];
|
||||
|
||||
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
|
||||
let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null;
|
||||
loggerWithEventDetails.debug(
|
||||
"Going to create booking in DB now",
|
||||
@@ -2077,7 +2061,25 @@ async function handler(
|
||||
);
|
||||
|
||||
try {
|
||||
booking = await createBooking();
|
||||
booking = await createBooking({
|
||||
originalRescheduledBooking,
|
||||
evt,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
reqBodyUser: reqBody.user,
|
||||
reqBodyMetadata: reqBody.metadata,
|
||||
reqBodyRecurringEventId: reqBody.recurringEventId,
|
||||
uid,
|
||||
responses,
|
||||
isConfirmedByDefault,
|
||||
smsReminderNumber,
|
||||
organizerUser,
|
||||
rescheduleReason,
|
||||
eventType,
|
||||
bookerEmail,
|
||||
paymentAppData,
|
||||
changedOrganizer,
|
||||
});
|
||||
|
||||
// @NOTE: Add specific try catch for all subsequent async calls to avoid error
|
||||
// Sync Services
|
||||
@@ -2208,11 +2210,18 @@ async function handler(
|
||||
? [originalRescheduledBooking?.user.destinationCalendar]
|
||||
: evt.destinationCalendar;
|
||||
|
||||
if (changedOrganizer) {
|
||||
evt.title = getEventName(eventNameObject);
|
||||
// location might changed and will be new created in eventManager.create (organizer default location)
|
||||
evt.videoCallData = undefined;
|
||||
}
|
||||
|
||||
const updateManager = await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer
|
||||
changedOrganizer,
|
||||
newDesinationCalendar
|
||||
);
|
||||
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
@@ -2221,24 +2230,12 @@ async function handler(
|
||||
|
||||
results = updateManager.results;
|
||||
referencesToCreate = updateManager.referencesToCreate;
|
||||
const isThereAnIntegrationError = results && results.some((res) => !res.success);
|
||||
if (isThereAnIntegrationError) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
loggerWithEventDetails.error(
|
||||
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
|
||||
safeStringify({ error, results })
|
||||
);
|
||||
} else {
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
}
|
||||
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
||||
results,
|
||||
@@ -2248,98 +2245,88 @@ async function handler(
|
||||
metadata = videoMetadata;
|
||||
videoCallUrl = _videoCallUrl;
|
||||
|
||||
//if organizer changed we need to create a new booking (reschedule only cancels the old one)
|
||||
if (changedOrganizer) {
|
||||
evt.destinationCalendar = newDesinationCalendar;
|
||||
evt.title = getEventName(eventNameObject);
|
||||
const isThereAnIntegrationError = results && results.some((res) => !res.success);
|
||||
|
||||
// location might changed and will be new created in eventManager.create (organizer default location)
|
||||
evt.videoCallData = undefined;
|
||||
const createManager = await eventManager.create(evt);
|
||||
if (isThereAnIntegrationError) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
|
||||
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
loggerWithEventDetails.error(
|
||||
`EventManager.reschedule failure in some of the integrations ${organizerUser.username}`,
|
||||
safeStringify({ error, results })
|
||||
);
|
||||
} else {
|
||||
if (results.length) {
|
||||
// Handle Google Meet results
|
||||
// We use the original booking location since the evt location changes to daily
|
||||
if (bookingLocation === MeetLocationType) {
|
||||
const googleMeetResult = {
|
||||
appName: GoogleMeetMetadata.name,
|
||||
type: "conferencing",
|
||||
uid: results[0].uid,
|
||||
originalEvent: results[0].originalEvent,
|
||||
};
|
||||
|
||||
results = createManager.results;
|
||||
referencesToCreate = createManager.referencesToCreate;
|
||||
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
|
||||
// Find index of google_calendar inside createManager.referencesToCreate
|
||||
const googleCalIndex = updateManager.referencesToCreate.findIndex(
|
||||
(ref) => ref.type === "google_calendar"
|
||||
);
|
||||
const googleCalResult = results[googleCalIndex];
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking rescheduling failed",
|
||||
};
|
||||
|
||||
loggerWithEventDetails.error(
|
||||
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
|
||||
safeStringify({ error, results })
|
||||
);
|
||||
} else {
|
||||
if (results.length) {
|
||||
// Handle Google Meet results
|
||||
// We use the original booking location since the evt location changes to daily
|
||||
if (bookingLocation === MeetLocationType) {
|
||||
const googleMeetResult = {
|
||||
appName: GoogleMeetMetadata.name,
|
||||
type: "conferencing",
|
||||
uid: results[0].uid,
|
||||
originalEvent: results[0].originalEvent,
|
||||
};
|
||||
|
||||
// Find index of google_calendar inside createManager.referencesToCreate
|
||||
const googleCalIndex = createManager.referencesToCreate.findIndex(
|
||||
(ref) => ref.type === "google_calendar"
|
||||
);
|
||||
const googleCalResult = results[googleCalIndex];
|
||||
|
||||
if (!googleCalResult) {
|
||||
loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location");
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: false,
|
||||
calWarnings: [tOrganizer("google_meet_warning")],
|
||||
});
|
||||
}
|
||||
|
||||
if (googleCalResult?.createdEvent?.hangoutLink) {
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Add google_meet to referencesToCreate in the same index as google_calendar
|
||||
createManager.referencesToCreate[googleCalIndex] = {
|
||||
...createManager.referencesToCreate[googleCalIndex],
|
||||
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
||||
};
|
||||
|
||||
// Also create a new referenceToCreate with type video for google_meet
|
||||
createManager.referencesToCreate.push({
|
||||
type: "google_meet_video",
|
||||
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
||||
uid: googleCalResult.uid,
|
||||
credentialId: createManager.referencesToCreate[googleCalIndex].credentialId,
|
||||
});
|
||||
} else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) {
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
if (!googleCalResult) {
|
||||
loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location");
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: false,
|
||||
calWarnings: [tOrganizer("google_meet_warning")],
|
||||
});
|
||||
}
|
||||
|
||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, booking);
|
||||
videoCallUrl =
|
||||
metadata.hangoutLink ||
|
||||
results[0].createdEvent?.url ||
|
||||
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
|
||||
videoCallUrl;
|
||||
if (googleCalResult?.createdEvent?.hangoutLink) {
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Add google_meet to referencesToCreate in the same index as google_calendar
|
||||
updateManager.referencesToCreate[googleCalIndex] = {
|
||||
...updateManager.referencesToCreate[googleCalIndex],
|
||||
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
||||
};
|
||||
|
||||
// Also create a new referenceToCreate with type video for google_meet
|
||||
updateManager.referencesToCreate.push({
|
||||
type: "google_meet_video",
|
||||
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
||||
uid: googleCalResult.uid,
|
||||
credentialId: updateManager.referencesToCreate[googleCalIndex].credentialId,
|
||||
});
|
||||
} else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) {
|
||||
results.push({
|
||||
...googleMeetResult,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, booking);
|
||||
videoCallUrl =
|
||||
metadata.hangoutLink ||
|
||||
results[0].createdEvent?.url ||
|
||||
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
|
||||
videoCallUrl;
|
||||
}
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
}
|
||||
|
||||
evt.appsStatus = handleAppsStatus(results, booking);
|
||||
|
||||
@@ -67,7 +67,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
||||
},
|
||||
onError() {
|
||||
setCheckedInternal(false);
|
||||
showToast(`Something went wrong when toggling "${title}""`, "error");
|
||||
showToast(`Something went wrong when toggling "${title}"`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -77,9 +77,10 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
||||
<Switch
|
||||
id={externalId}
|
||||
checked={checkedInternal}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
disabled={mutation.isLoading}
|
||||
onCheckedChange={async (isOn: boolean) => {
|
||||
setCheckedInternal(isOn);
|
||||
mutation.mutate({ isOn });
|
||||
await mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getBooking } from "@calcom/lib/payment/getBooking";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -38,7 +39,7 @@ export async function handleStripePaymentSuccess(event: Stripe.Event) {
|
||||
});
|
||||
|
||||
if (!payment?.bookingId) {
|
||||
log.error(JSON.stringify(paymentIntent), JSON.stringify(payment));
|
||||
log.error("Stripe: Payment Not Found", safeStringify(paymentIntent), safeStringify(payment));
|
||||
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
}
|
||||
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
|
||||
@@ -10,6 +10,7 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -33,11 +34,21 @@ type FormValues = {
|
||||
const AddNewTeamMembers = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const session = useSession();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1;
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: session.status === "authenticated" }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const event = searchParams?.get("event");
|
||||
if (event === "team_created") {
|
||||
telemetry.event(telemetryEventTypes.team_created);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
|
||||
@@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({
|
||||
)}
|
||||
<hr className="border-subtle my-6" />
|
||||
<Button
|
||||
data-testid="publish-button"
|
||||
EndIcon={!orgBranding ? ArrowRight : undefined}
|
||||
color="primary"
|
||||
className="w-full justify-center"
|
||||
disabled={publishTeamMutation.isLoading}
|
||||
onClick={() => {
|
||||
if (orgBranding) {
|
||||
router.push("/settings/teams");
|
||||
} else {
|
||||
publishTeamMutation.mutate({ teamId });
|
||||
}
|
||||
router.push(`/settings/teams/${teamId}/profile`);
|
||||
}}>
|
||||
{t(orgBranding ? "finish" : "team_publish")}
|
||||
{t("finish")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,14 +4,15 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Form, ImageUploader, TextField, Alert, Label } from "@calcom/ui";
|
||||
import { ArrowRight, Plus } from "@calcom/ui/components/icon";
|
||||
import { Alert, Button, Form, TextField } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOrgBranding } from "../../organizations/context/provider";
|
||||
import type { NewTeamFormValues } from "../lib/types";
|
||||
@@ -21,8 +22,19 @@ const querySchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
|
||||
const flag = isTeamBillingEnabledClient
|
||||
? {
|
||||
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
|
||||
submitLabel: "checkout",
|
||||
}
|
||||
: {
|
||||
telemetryEvent: telemetryEventTypes.team_created,
|
||||
submitLabel: "continue",
|
||||
};
|
||||
|
||||
export const CreateANewTeamForm = () => {
|
||||
const { t } = useLocale();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const params = useParamsWithFallback();
|
||||
@@ -42,8 +54,8 @@ export const CreateANewTeamForm = () => {
|
||||
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
telemetry.event(telemetryEventTypes.team_created);
|
||||
router.push(`/settings/teams/${data.id}/onboard-members`);
|
||||
telemetry.event(flag.telemetryEvent);
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message === "team_url_taken") {
|
||||
@@ -81,6 +93,10 @@ export const CreateANewTeamForm = () => {
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<TextField
|
||||
disabled={
|
||||
/* E2e is too fast and it tries to fill this way before the form is ready */
|
||||
!isLocaleReady || createTeamMutation.isLoading
|
||||
}
|
||||
className="mt-2"
|
||||
placeholder="Acme Inc."
|
||||
name="name"
|
||||
@@ -128,38 +144,6 @@ export const CreateANewTeamForm = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
control={newTeamFormMethods.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>{t("team_logo")}</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Plus className="text-subtle h-6 w-6" />}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
newTeamFormMethods.setValue("logo", newAvatar);
|
||||
createTeamMutation.reset();
|
||||
}}
|
||||
imageSrc={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
disabled={createTeamMutation.isLoading}
|
||||
@@ -174,7 +158,7 @@ export const CreateANewTeamForm = () => {
|
||||
EndIcon={ArrowRight}
|
||||
type="submit"
|
||||
className="w-full justify-center">
|
||||
{t("continue")}
|
||||
{t(flag.submitLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -6,11 +6,12 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { isEmail } from "@calcom/trpc/server/routers/viewer/teams/util";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -199,7 +200,10 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth={true}
|
||||
onValueChange={(val) => setModalInputMode(val as ModalMode)}
|
||||
onValueChange={(val) => {
|
||||
setModalInputMode(val as ModalMode);
|
||||
newMemberFormMethods.clearErrors();
|
||||
}}
|
||||
defaultValue={modalImportMode}
|
||||
options={toggleGroupOptions}
|
||||
/>
|
||||
@@ -213,8 +217,10 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
name="emailOrUsername"
|
||||
control={newMemberFormMethods.control}
|
||||
rules={{
|
||||
required: t("enter_email_or_username"),
|
||||
required: isOrg ? t("enter_email") : t("enter_email_or_username"),
|
||||
validate: (value) => {
|
||||
// orgs can only invite members by email
|
||||
if (typeof value === "string" && isOrg && !isEmail(value)) return t("enter_email");
|
||||
if (typeof value === "string")
|
||||
return validateUniqueInvite(value) || t("member_already_invited");
|
||||
},
|
||||
@@ -241,7 +247,14 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
name="emailOrUsername"
|
||||
control={newMemberFormMethods.control}
|
||||
rules={{
|
||||
required: t("enter_email_or_username"),
|
||||
required: t("enter_email"),
|
||||
validate: (value) => {
|
||||
if (Array.isArray(value) && value.some((email) => !isEmail(email)))
|
||||
return t("enter_emails");
|
||||
if (Array.isArray(value) && value.length > MAX_NB_INVITES)
|
||||
return t("too_many_invites", { nbUsers: MAX_NB_INVITES });
|
||||
if (typeof value === "string" && !isEmail(value)) return t("enter_email");
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<>
|
||||
|
||||
@@ -75,22 +75,6 @@ export default function TeamList(props: Props) {
|
||||
child: t("invite"),
|
||||
}}
|
||||
/>
|
||||
{/* @TODO: uncomment once managed event types is live
|
||||
<Card
|
||||
icon={<Unlock className="h-5 w-5 text-blue-700" />}
|
||||
variant="basic"
|
||||
title={t("create_a_managed_event")}
|
||||
description={t("create_a_one_one_template")}
|
||||
actionButton={{
|
||||
href:
|
||||
"/event-types?dialog=new-eventtype&eventPage=team%2F" +
|
||||
team.slug +
|
||||
"&teamId=" +
|
||||
team.id +
|
||||
"&managed=true",
|
||||
child: t("create"),
|
||||
}}
|
||||
/> */}
|
||||
<Card
|
||||
icon={<Users className="h-5 w-5 text-orange-700" />}
|
||||
variant="basic"
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ConfirmationDialogContent,
|
||||
@@ -104,11 +105,15 @@ export default function TeamListItem(props: Props) {
|
||||
<div className="ms-3 inline-block truncate">
|
||||
<span className="text-default text-sm font-bold">{team.name}</span>
|
||||
<span className="text-muted block text-xs">
|
||||
{team.slug
|
||||
? orgBranding
|
||||
? `${orgBranding.fullDomain}/${team.slug}`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`
|
||||
: "Unpublished team"}
|
||||
{team.slug ? (
|
||||
orgBranding ? (
|
||||
`${orgBranding.fullDomain}/${team.slug}`
|
||||
) : (
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`
|
||||
)
|
||||
) : (
|
||||
<Badge>{t("upgrade")}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,13 +185,17 @@ export default function TeamListItem(props: Props) {
|
||||
)}
|
||||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
className="flex-grow cursor-pointer truncate text-sm"
|
||||
title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</Link>
|
||||
team.slug ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
className="flex-grow cursor-pointer truncate text-sm"
|
||||
title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</Link>
|
||||
) : (
|
||||
<TeamPublishSection teamId={team.id}>{teamInfo}</TeamPublishSection>
|
||||
)
|
||||
) : (
|
||||
teamInfo
|
||||
)}
|
||||
@@ -388,3 +397,26 @@ const TeamPublishButton = ({ teamId }: { teamId: number }) => {
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamPublishSection = ({ children, teamId }: { children: React.ReactNode; teamId: number }) => {
|
||||
const router = useRouter();
|
||||
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className="block flex-grow cursor-pointer truncate text-left text-sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
publishTeamMutation.mutate({ teamId });
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,7 +102,6 @@ export function TeamsListing() {
|
||||
<TeamList teams={invites} pending />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpgradeTip
|
||||
plan="team"
|
||||
title={t("calcom_is_better_with_team", { appName: APP_NAME })}
|
||||
|
||||
@@ -29,6 +29,51 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
|
||||
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to generate a checkout session when trying to create a team
|
||||
*/
|
||||
export const generateTeamCheckoutSession = async ({
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
}: {
|
||||
teamName: string;
|
||||
teamSlug: string;
|
||||
userId: number;
|
||||
}) => {
|
||||
const customer = await getStripeCustomerIdFromUserId(userId);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
|
||||
line_items: [
|
||||
{
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
/**Initially it will be just the team owner */
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
},
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams
|
||||
*/
|
||||
export const purchaseTeamSubscription = async (input: {
|
||||
teamId: number;
|
||||
seats: number;
|
||||
|
||||
@@ -194,7 +194,11 @@ const ProfileView = () => {
|
||||
<Dialog>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Trash2}>
|
||||
<Button
|
||||
color="destructive"
|
||||
className="border"
|
||||
StartIcon={Trash2}
|
||||
data-testid="disband-team-button">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
export function UpgradeTip({
|
||||
dark,
|
||||
@@ -29,10 +30,14 @@ export function UpgradeTip({
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { isLoading, hasTeamPlan } = useHasTeamPlan();
|
||||
const { data } = trpc.viewer.teams.getUpgradeable.useQuery();
|
||||
|
||||
const hasEnterprisePlan = false;
|
||||
//const { isLoading , hasEnterprisePlan } = useHasEnterprisePlan();
|
||||
|
||||
if (plan === "team" && hasTeamPlan) return children;
|
||||
const hasUnpublishedTeam = !!data?.[0];
|
||||
|
||||
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return children;
|
||||
|
||||
if (plan === "enterprise" && hasEnterprisePlan) return children;
|
||||
|
||||
@@ -44,7 +49,7 @@ export function UpgradeTip({
|
||||
<picture className="absolute min-h-[295px] w-full rounded-lg object-cover">
|
||||
<source srcSet={`${background}-dark.jpg`} media="(prefers-color-scheme: dark)" />
|
||||
<img
|
||||
className="absolute min-h-[295px] w-full rounded-lg object-cover object-left md:object-center"
|
||||
className="absolute min-h-[295px] w-full select-none rounded-lg object-cover object-left md:object-center"
|
||||
src={`${background}.jpg`}
|
||||
loading="lazy"
|
||||
alt={title}
|
||||
|
||||
@@ -5,6 +5,8 @@ import dayjs from "@calcom/dayjs";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { jsonParse } from "./sendPayload";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
@@ -27,6 +29,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await fetch(job.subscriberUrl, {
|
||||
method: "POST",
|
||||
body: job.payload,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
!job.payload || jsonParse(job.payload) ? "application/json" : "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error running webhook trigger (retry count: ${job.retryCount}): ${error}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { v4 } from "uuid";
|
||||
import { getHumanReadableLocationValue } from "@calcom/core/location";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { ApiKey } from "@calcom/prisma/client";
|
||||
@@ -75,7 +76,10 @@ export async function addSubscription({
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
log.error(`Error creating subscription for ${teamId ? `team ${teamId}` : `user ${userId}`}.`);
|
||||
log.error(
|
||||
`Error creating subscription for ${teamId ? `team ${teamId}` : `user ${userId}`}.`,
|
||||
safeStringify(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +161,8 @@ export async function deleteSubscription({
|
||||
log.error(
|
||||
`Error deleting subscription for user ${
|
||||
teamId ? `team ${teamId}` : `userId ${userId}`
|
||||
}, webhookId ${webhookId}`
|
||||
}, webhookId ${webhookId}`,
|
||||
safeStringify(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +265,10 @@ export async function listBookings(
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
log.error(`Error retrieving list of bookings for ${teamId ? `team ${teamId}` : `user ${userId}`}.`);
|
||||
log.error(
|
||||
`Error retrieving list of bookings for ${teamId ? `team ${teamId}` : `user ${userId}`}.`,
|
||||
safeStringify(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ function applyTemplate(template: string, data: WebhookDataType, contentType: Con
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function jsonParse(jsonString: string) {
|
||||
export function jsonParse(jsonString: string) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
|
||||
@@ -88,7 +88,7 @@ export const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
);
|
||||
/** Self hosted shouldn't checkout when creating teams unless required */
|
||||
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && (!IS_SELF_HOSTED || HOSTED_CAL_FEATURES);
|
||||
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES;
|
||||
export const FULL_NAME_LENGTH_MAX_LIMIT = 50;
|
||||
export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5";
|
||||
|
||||
@@ -119,3 +119,6 @@ export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN(
|
||||
export const IS_PREMIUM_USERNAME_ENABLED =
|
||||
(IS_CALCOM || (process.env.NEXT_PUBLIC_IS_E2E && IS_STRIPE_ENABLED)) &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY;
|
||||
|
||||
// Max number of invites to join a team/org that can be sent at once
|
||||
export const MAX_NB_INVITES = 100;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function deriveAppDictKeyFromType(appType: string, dict: Record<string, u
|
||||
|
||||
return appType;
|
||||
|
||||
// const categories = ["video", "other", "calendar", "web3", "payment", "messaging"];
|
||||
// const categories = ["video", "other", "calendar", "payment", "messaging"];
|
||||
|
||||
// // Instead of doing a blind split at _ and using the first part, apply this hack only on strings that match legacy type.
|
||||
// // Transform zoomvideo to zoom
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useRouterQuery } from "./useRouterQuery";
|
||||
@@ -46,6 +46,17 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
|
||||
return {} as Output;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedQuerySchema.success && parsedQuerySchema.data) {
|
||||
Object.entries(parsedQuerySchema.data).forEach(([key, value]) => {
|
||||
if (key in unparsedQuery || !value) return;
|
||||
const search = new URLSearchParams(parsedQuery);
|
||||
search.set(String(key), String(value));
|
||||
router.replace(`${pathname}?${search.toString()}`);
|
||||
});
|
||||
}
|
||||
}, [parsedQuerySchema, schema, router, pathname, unparsedQuery, parsedQuery]);
|
||||
|
||||
if (parsedQuerySchema.success) parsedQuery = parsedQuerySchema.data;
|
||||
else if (!parsedQuerySchema.success) console.error(parsedQuerySchema.error);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`[[redactError]`] });
|
||||
|
||||
@@ -19,7 +20,7 @@ export const redactError = <T extends Error | unknown>(error: T) => {
|
||||
}
|
||||
log.debug("Type of Error: ", error.constructor);
|
||||
if (shouldRedact(error)) {
|
||||
log.error("Error: ", JSON.stringify(error));
|
||||
log.error("Error: ", safeStringify(error));
|
||||
return new Error("An error occured while querying the database.");
|
||||
}
|
||||
return error;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const telemetryEventTypes = {
|
||||
onboardingFinished: "onboarding_finished",
|
||||
onboardingStarted: "onboarding_started",
|
||||
signup: "signup",
|
||||
team_checkout_session_created: "team_checkout_session_created",
|
||||
team_created: "team_created",
|
||||
slugReplacementAction: "slug_replacement_action",
|
||||
org_created: "org_created",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Session } from "next-auth";
|
||||
import type { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import prisma, { readonlyPrisma } from "@calcom/prisma";
|
||||
import type { SelectedCalendar, User as PrismaUser } from "@calcom/prisma/client";
|
||||
|
||||
@@ -12,6 +13,7 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
type CreateContextOptions = CreateNextContextOptions | GetServerSidePropsContext;
|
||||
|
||||
export type CreateInnerContextOptions = {
|
||||
sourceIp?: string;
|
||||
session?: Session | null;
|
||||
locale: string;
|
||||
user?:
|
||||
@@ -64,8 +66,12 @@ export async function createContextInner(opts: CreateInnerContextOptions) {
|
||||
*/
|
||||
export const createContext = async ({ req, res }: CreateContextOptions, sessionGetter?: GetSessionFn) => {
|
||||
const locale = await getLocale(req);
|
||||
|
||||
// This type may not be accurate if this request is coming from SSG init but they both should satisfy the requirements of getIP.
|
||||
// TODO: @sean - figure out a way to make getIP be happy with trpc req. params
|
||||
const sourceIp = getIP(req as NextApiRequest);
|
||||
const session = !!sessionGetter ? await sessionGetter({ req, res }) : null;
|
||||
const contextInner = await createContextInner({ locale, session });
|
||||
const contextInner = await createContextInner({ locale, session, sourceIp });
|
||||
return {
|
||||
...contextInner,
|
||||
req,
|
||||
|
||||
@@ -292,7 +292,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
}`
|
||||
);
|
||||
const getStartTime = (startTimeInput: string, timeZone?: string) => {
|
||||
const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes");
|
||||
const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice || 1, "minutes");
|
||||
const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone);
|
||||
|
||||
return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { importHandler, router } from "../../../trpc";
|
||||
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
|
||||
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
|
||||
import { ZCreateInputSchema } from "./create.schema";
|
||||
@@ -20,457 +20,139 @@ import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
|
||||
|
||||
type TeamsRouterHandlerCache = {
|
||||
get?: typeof import("./get.handler").getHandler;
|
||||
list?: typeof import("./list.handler").listHandler;
|
||||
listOwnedTeams?: typeof import("./listOwnedTeams.handler").listOwnedTeamsHandler;
|
||||
create?: typeof import("./create.handler").createHandler;
|
||||
update?: typeof import("./update.handler").updateHandler;
|
||||
delete?: typeof import("./delete.handler").deleteHandler;
|
||||
removeMember?: typeof import("./removeMember.handler").removeMemberHandler;
|
||||
inviteMember?: typeof import("./inviteMember/inviteMember.handler").inviteMemberHandler;
|
||||
acceptOrLeave?: typeof import("./acceptOrLeave.handler").acceptOrLeaveHandler;
|
||||
changeMemberRole?: typeof import("./changeMemberRole.handler").changeMemberRoleHandler;
|
||||
getMemberAvailability?: typeof import("./getMemberAvailability.handler").getMemberAvailabilityHandler;
|
||||
getMembershipbyUser?: typeof import("./getMembershipbyUser.handler").getMembershipbyUserHandler;
|
||||
updateMembership?: typeof import("./updateMembership.handler").updateMembershipHandler;
|
||||
publish?: typeof import("./publish.handler").publishHandler;
|
||||
getUpgradeable?: typeof import("./getUpgradeable.handler").getUpgradeableHandler;
|
||||
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
|
||||
hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler;
|
||||
listInvites?: typeof import("./listInvites.handler").listInvitesHandler;
|
||||
createInvite?: typeof import("./createInvite.handler").createInviteHandler;
|
||||
setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler;
|
||||
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
|
||||
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
|
||||
hasEditPermissionForUser?: typeof import("./hasEditPermissionForUser.handler").hasEditPermissionForUser;
|
||||
resendInvitation?: typeof import("./resendInvitation.handler").resendInvitationHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
|
||||
const NAMESPACE = "teams";
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
export const viewerTeamsRouter = router({
|
||||
// Retrieves team by id
|
||||
get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.get) {
|
||||
UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.get) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.get({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
get: authedProcedure.input(ZGetInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("get"), () => import("./get.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
// Returns teams I a member of
|
||||
list: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.list) {
|
||||
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.list) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.list({
|
||||
ctx,
|
||||
});
|
||||
list: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("list"), () => import("./list.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
// Returns Teams I am a owner/admin of
|
||||
listOwnedTeams: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) {
|
||||
UNSTABLE_HANDLER_CACHE.listOwnedTeams = await import("./listOwnedTeams.handler").then(
|
||||
(mod) => mod.listOwnedTeamsHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.listOwnedTeams({
|
||||
ctx,
|
||||
});
|
||||
listOwnedTeams: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("list"), () => import("./list.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.create) {
|
||||
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.create) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.create({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
create: authedProcedure.input(ZCreateInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("create"), () => import("./create.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
// Allows team owner to update team metadata
|
||||
update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.update) {
|
||||
UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.update) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.update({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
update: authedProcedure.input(ZUpdateInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("update"), () => import("./update.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.delete) {
|
||||
UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.delete) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.delete({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
delete: authedProcedure.input(ZDeleteInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("delete"), () => import("./delete.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.removeMember) {
|
||||
UNSTABLE_HANDLER_CACHE.removeMember = await import("./removeMember.handler").then(
|
||||
(mod) => mod.removeMemberHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.removeMember) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.removeMember({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("removeMember"), () => import("./removeMember.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMember) {
|
||||
UNSTABLE_HANDLER_CACHE.inviteMember = await import("./inviteMember/inviteMember.handler").then(
|
||||
(mod) => mod.inviteMemberHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMember) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.inviteMember({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("inviteMember"),
|
||||
() => import("./inviteMember/inviteMember.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) {
|
||||
UNSTABLE_HANDLER_CACHE.acceptOrLeave = await import("./acceptOrLeave.handler").then(
|
||||
(mod) => mod.acceptOrLeaveHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.acceptOrLeave({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("acceptOrLeave"), () => import("./acceptOrLeave.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) {
|
||||
UNSTABLE_HANDLER_CACHE.changeMemberRole = await import("./changeMemberRole.handler").then(
|
||||
(mod) => mod.changeMemberRoleHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.changeMemberRole({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("changeMemberRole"),
|
||||
() => import("./changeMemberRole.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
getMemberAvailability: authedProcedure
|
||||
.input(ZGetMemberAvailabilityInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) {
|
||||
UNSTABLE_HANDLER_CACHE.getMemberAvailability = await import("./getMemberAvailability.handler").then(
|
||||
(mod) => mod.getMemberAvailabilityHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getMemberAvailability({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
getMembershipbyUser: authedProcedure
|
||||
.input(ZGetMembershipbyUserInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) {
|
||||
UNSTABLE_HANDLER_CACHE.getMembershipbyUser = await import("./getMembershipbyUser.handler").then(
|
||||
(mod) => mod.getMembershipbyUserHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getMembershipbyUser({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateMembership) {
|
||||
UNSTABLE_HANDLER_CACHE.updateMembership = await import("./updateMembership.handler").then(
|
||||
(mod) => mod.updateMembershipHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateMembership) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.updateMembership({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
getMemberAvailability: authedProcedure.input(ZGetMemberAvailabilityInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getMemberAvailability"),
|
||||
() => import("./getMemberAvailability.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
publish: authedProcedure.input(ZPublishInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.publish) {
|
||||
UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.publish) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.publish({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
getMembershipbyUser: authedProcedure.input(ZGetMembershipbyUserInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getMembershipbyUser"),
|
||||
() => import("./getMembershipbyUser.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("updateMembership"),
|
||||
() => import("./updateMembership.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
publish: authedProcedure.input(ZPublishInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("publish"), () => import("./publish.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
/** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */
|
||||
getUpgradeable: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) {
|
||||
UNSTABLE_HANDLER_CACHE.getUpgradeable = await import("./getUpgradeable.handler").then(
|
||||
(mod) => mod.getUpgradeableHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getUpgradeable({
|
||||
ctx,
|
||||
});
|
||||
getUpgradeable: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getUpgradeable"),
|
||||
() => import("./getUpgradeable.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
listMembers: authedProcedure.input(ZListMembersInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
|
||||
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
|
||||
(mod) => mod.listMembersHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.listMembers({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
listMembers: authedProcedure.input(ZListMembersInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
hasTeamPlan: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) {
|
||||
UNSTABLE_HANDLER_CACHE.hasTeamPlan = await import("./hasTeamPlan.handler").then(
|
||||
(mod) => mod.hasTeamPlanHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.hasTeamPlan({
|
||||
ctx,
|
||||
});
|
||||
hasTeamPlan: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("hasTeamPlan"), () => import("./hasTeamPlan.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
listInvites: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.listInvites) {
|
||||
UNSTABLE_HANDLER_CACHE.listInvites = await import("./listInvites.handler").then(
|
||||
(mod) => mod.listInvitesHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.listInvites) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.listInvites({
|
||||
ctx,
|
||||
});
|
||||
listInvites: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("listInvites"), () => import("./listInvites.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
|
||||
UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then(
|
||||
(mod) => mod.createInviteHandler
|
||||
);
|
||||
}
|
||||
|
||||
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.createInvite({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("createInvite"), () => import("./createInvite.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
setInviteExpiration: authedProcedure
|
||||
.input(ZSetInviteExpirationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
|
||||
UNSTABLE_HANDLER_CACHE.setInviteExpiration = await import("./setInviteExpiration.handler").then(
|
||||
(mod) => mod.setInviteExpirationHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.setInviteExpiration({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
|
||||
UNSTABLE_HANDLER_CACHE.deleteInvite = await import("./deleteInvite.handler").then(
|
||||
(mod) => mod.deleteInviteHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.deleteInvite({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
setInviteExpiration: authedProcedure.input(ZSetInviteExpirationInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("setInviteExpiration"),
|
||||
() => import("./setInviteExpiration.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
inviteMemberByToken: authedProcedure
|
||||
.input(ZInviteMemberByTokenSchemaInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
|
||||
UNSTABLE_HANDLER_CACHE.inviteMemberByToken = await import("./inviteMemberByToken.handler").then(
|
||||
(mod) => mod.inviteMemberByTokenHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.inviteMemberByToken({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
hasEditPermissionForUser: authedProcedure
|
||||
.input(ZHasEditPermissionForUserSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser) {
|
||||
UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser = await import(
|
||||
"./hasEditPermissionForUser.handler"
|
||||
).then((mod) => mod.hasEditPermissionForUser);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.resendInvitation) {
|
||||
UNSTABLE_HANDLER_CACHE.resendInvitation = await import("./resendInvitation.handler").then(
|
||||
(mod) => mod.resendInvitationHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.resendInvitation) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.resendInvitation({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("deleteInvite"), () => import("./deleteInvite.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
inviteMemberByToken: authedProcedure.input(ZInviteMemberByTokenSchemaInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("inviteMemberByToken"),
|
||||
() => import("./inviteMemberByToken.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
hasEditPermissionForUser: authedProcedure.input(ZHasEditPermissionForUserSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("hasEditPermissionForUser"),
|
||||
() => import("./hasEditPermissionForUser.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("resendInvitation"),
|
||||
() => import("./resendInvitation.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -48,3 +48,5 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default acceptOrLeaveHandler;
|
||||
|
||||
@@ -71,3 +71,5 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
|
||||
// Sync Services: Close.com
|
||||
closeComUpsertTeamUser(membership.team, membership.user, membership.role);
|
||||
};
|
||||
|
||||
export default changeMemberRoleHandler;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -14,6 +16,34 @@ type CreateOptions = {
|
||||
input: TCreateInputSchema;
|
||||
};
|
||||
|
||||
const generateCheckoutSession = async ({
|
||||
teamSlug,
|
||||
teamName,
|
||||
userId,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
teamName: string;
|
||||
userId: number;
|
||||
}) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
console.info("Team billing is disabled, not generating a checkout session.");
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutSession = await generateTeamCheckoutSession({
|
||||
teamSlug,
|
||||
teamName,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!checkoutSession.url)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed retrieving a checkout session URL.",
|
||||
});
|
||||
return { url: checkoutSession.url, message: "Payment required to publish team" };
|
||||
};
|
||||
|
||||
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
const { user } = ctx;
|
||||
const { slug, name, logo } = input;
|
||||
@@ -45,28 +75,26 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" });
|
||||
}
|
||||
|
||||
// Ensure that the user is not duplicating a requested team
|
||||
const duplicatedRequest = await prisma.team.findFirst({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
// If the user is not a part of an org, then make them pay before creating the team
|
||||
if (!isOrgChildTeam) {
|
||||
const checkoutSession = await generateCheckoutSession({
|
||||
teamSlug: slug,
|
||||
teamName: name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (duplicatedRequest) {
|
||||
return duplicatedRequest;
|
||||
// If there is a checkout session, return it. Otherwise, it means it's disabled.
|
||||
if (checkoutSession)
|
||||
return {
|
||||
url: checkoutSession.url,
|
||||
message: checkoutSession.message,
|
||||
team: null,
|
||||
};
|
||||
}
|
||||
|
||||
const createTeam = await prisma.team.create({
|
||||
const createdTeam = await prisma.team.create({
|
||||
data: {
|
||||
...(isOrgChildTeam ? { slug } : {}),
|
||||
slug,
|
||||
name,
|
||||
logo,
|
||||
members: {
|
||||
@@ -76,17 +104,18 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
metadata: !isOrgChildTeam
|
||||
? {
|
||||
requestedSlug: slug,
|
||||
}
|
||||
: undefined,
|
||||
...(isOrgChildTeam && { parentId: user.organizationId }),
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER);
|
||||
closeComUpsertTeamUser(createdTeam, ctx.user, MembershipRole.OWNER);
|
||||
|
||||
return createTeam;
|
||||
return {
|
||||
url: `${WEBAPP_URL}/settings/teams/${createdTeam.id}/onboard-members`,
|
||||
message: "Team billing is disabled, not generating a checkout session.",
|
||||
team: createdTeam,
|
||||
};
|
||||
};
|
||||
|
||||
export default createHandler;
|
||||
|
||||
@@ -59,3 +59,5 @@ async function getInviteLink(token = "", isOrg = false, orgMembers = 0) {
|
||||
if (isOrg || orgMembers > 0) return orgInviteLink;
|
||||
return teamInviteLink;
|
||||
}
|
||||
|
||||
export default createInviteHandler;
|
||||
|
||||
@@ -82,3 +82,5 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
// Sync Services: Close.cm
|
||||
closeComDeleteTeam(deletedTeam);
|
||||
};
|
||||
|
||||
export default deleteHandler;
|
||||
|
||||
@@ -31,3 +31,5 @@ export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) =
|
||||
|
||||
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
|
||||
};
|
||||
|
||||
export default deleteInviteHandler;
|
||||
|
||||
@@ -36,3 +36,5 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default getHandler;
|
||||
|
||||
@@ -54,3 +54,5 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai
|
||||
{ user: member.user }
|
||||
);
|
||||
};
|
||||
|
||||
export default getMemberAvailabilityHandler;
|
||||
|
||||
@@ -29,3 +29,5 @@ export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipby
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default getMembershipbyUserHandler;
|
||||
|
||||
@@ -43,3 +43,5 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
|
||||
});
|
||||
return teams;
|
||||
};
|
||||
|
||||
export default getUpgradeableHandler;
|
||||
|
||||
@@ -17,3 +17,5 @@ export const hasEditPermissionForUser = async ({ ctx, input }: HasEditPermission
|
||||
input,
|
||||
});
|
||||
};
|
||||
|
||||
export default hasEditPermissionForUser;
|
||||
|
||||
@@ -23,3 +23,5 @@ export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
|
||||
});
|
||||
return { hasTeamPlan: !!hasTeamPlan };
|
||||
};
|
||||
|
||||
export default hasTeamPlanHandler;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TInviteMemberInputSchema } from "./inviteMember.schema";
|
||||
import {
|
||||
checkPermissions,
|
||||
getTeamOrThrow,
|
||||
getEmailsToInvite,
|
||||
getUsernameOrEmailsToInvite,
|
||||
getOrgConnectionInfo,
|
||||
getIsOrgVerified,
|
||||
sendVerificationEmail,
|
||||
@@ -42,43 +42,54 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
|
||||
|
||||
const team = await getTeamOrThrow(input.teamId, input.isOrg);
|
||||
const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team);
|
||||
const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail);
|
||||
const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => {
|
||||
const usernameOrEmailsToInvite = await getUsernameOrEmailsToInvite(input.usernameOrEmail);
|
||||
const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => {
|
||||
return {
|
||||
...acc,
|
||||
[email]: getOrgConnectionInfo({
|
||||
[usernameOrEmail]: getOrgConnectionInfo({
|
||||
orgVerified,
|
||||
orgAutoAcceptDomain: autoAcceptEmailDomain,
|
||||
usersEmail: email,
|
||||
usersEmail: usernameOrEmail,
|
||||
team,
|
||||
isOrg: input.isOrg,
|
||||
}),
|
||||
};
|
||||
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
|
||||
const existingUsersWithMembersips = await getUsersToInvite({
|
||||
usernameOrEmail: emailsToInvite,
|
||||
usernamesOrEmails: usernameOrEmailsToInvite,
|
||||
isInvitedToOrg: input.isOrg,
|
||||
team,
|
||||
});
|
||||
const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email);
|
||||
const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email));
|
||||
const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce(
|
||||
(acc, user) => ({
|
||||
emails: user.email ? [...acc.emails, user.email] : acc.emails,
|
||||
usernames: user.username ? [...acc.usernames, user.username] : acc.usernames,
|
||||
}),
|
||||
{ emails: [], usernames: [] } as { emails: string[]; usernames: string[] }
|
||||
);
|
||||
const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter(
|
||||
(usernameOrEmail) =>
|
||||
!existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) &&
|
||||
!existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail)
|
||||
);
|
||||
|
||||
// deal with users to create and invite to team/org
|
||||
if (newUsersEmails.length) {
|
||||
if (newUsersEmailsOrUsernames.length) {
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
usernamesOrEmails: newUsersEmails,
|
||||
usernamesOrEmails: newUsersEmailsOrUsernames,
|
||||
input,
|
||||
connectionInfoMap: orgConnectInfoByEmail,
|
||||
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
|
||||
autoAcceptEmailDomain,
|
||||
parentId: team.parentId,
|
||||
});
|
||||
const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => {
|
||||
const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => {
|
||||
return sendVerificationEmail({
|
||||
usernameOrEmail,
|
||||
team,
|
||||
translation,
|
||||
ctx,
|
||||
input,
|
||||
connectionInfo: orgConnectInfoByEmail[usernameOrEmail],
|
||||
connectionInfo: orgConnectInfoByUsernameOrEmail[usernameOrEmail],
|
||||
});
|
||||
});
|
||||
sendEmails(sendVerifEmailsPromises);
|
||||
@@ -129,3 +140,5 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export default inviteMemberHandler;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_NB_INVITES } from "@calcom/lib/constants";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const ZInviteMemberInputSchema = z.object({
|
||||
teamId: z.number(),
|
||||
usernameOrEmail: z
|
||||
@@ -14,27 +13,26 @@ export const ZInviteMemberInputSchema = z.object({
|
||||
}
|
||||
return usernameOrEmail.map((item) => item.trim().toLowerCase());
|
||||
})
|
||||
.refine((value) => {
|
||||
let invalidEmail;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 100) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `You are limited to inviting a maximum of 100 users at once.`,
|
||||
});
|
||||
.refine(
|
||||
(value) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > MAX_NB_INVITES) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
invalidEmail = value.find((email) => !z.string().email().safeParse(email).success);
|
||||
} else {
|
||||
invalidEmail = !z.string().email().safeParse(value).success ? value : null;
|
||||
}
|
||||
if (invalidEmail) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invite failed because '${invalidEmail}' is not a valid email address`,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
return true;
|
||||
},
|
||||
{ message: `You are limited to inviting a maximum of ${MAX_NB_INVITES} users at once.` }
|
||||
)
|
||||
.refine(
|
||||
(value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return !value.some((email) => !z.string().email().safeParse(email).success);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Bulk invitations are restricted to email addresses only." }
|
||||
),
|
||||
role: z.nativeEnum(MembershipRole),
|
||||
language: z.string(),
|
||||
isOrg: z.boolean().default(false),
|
||||
|
||||
@@ -10,11 +10,12 @@ import type { TeamWithParent } from "./types";
|
||||
import type { Invitee, UserWithMembership } from "./utils";
|
||||
import {
|
||||
checkPermissions,
|
||||
getEmailsToInvite,
|
||||
getUsernameOrEmailsToInvite,
|
||||
getIsOrgVerified,
|
||||
getOrgConnectionInfo,
|
||||
validateInviteeEligibility,
|
||||
shouldAutoJoinIfInOrg,
|
||||
checkInputEmailIsValid,
|
||||
} from "./utils";
|
||||
|
||||
vi.mock("@calcom/lib/server/queries", () => {
|
||||
@@ -101,22 +102,35 @@ describe("Invite Member Utils", () => {
|
||||
await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
describe("getEmailsToInvite", () => {
|
||||
describe("getUsernameOrEmailsToInvite", () => {
|
||||
it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => {
|
||||
await expect(getEmailsToInvite([])).rejects.toThrow(TRPCError);
|
||||
await expect(getUsernameOrEmailsToInvite([])).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should return an array with one email if a string is provided", async () => {
|
||||
const result = await getEmailsToInvite("test@example.com");
|
||||
const result = await getUsernameOrEmailsToInvite("test@example.com");
|
||||
expect(result).toEqual(["test@example.com"]);
|
||||
});
|
||||
|
||||
it("should return an array with multiple emails if an array is provided", async () => {
|
||||
const result = await getEmailsToInvite(["test1@example.com", "test2@example.com"]);
|
||||
const result = await getUsernameOrEmailsToInvite(["test1@example.com", "test2@example.com"]);
|
||||
expect(result).toEqual(["test1@example.com", "test2@example.com"]);
|
||||
});
|
||||
});
|
||||
describe("checkInputEmailIsValid", () => {
|
||||
it("should throw a TRPCError with code BAD_REQUEST if the email is invalid", () => {
|
||||
const invalidEmail = "invalid-email";
|
||||
expect(() => checkInputEmailIsValid(invalidEmail)).toThrow(TRPCError);
|
||||
expect(() => checkInputEmailIsValid(invalidEmail)).toThrowError(
|
||||
"Invite failed because invalid-email is not a valid email address"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not throw an error if the email is valid", () => {
|
||||
const validEmail = "valid-email@example.com";
|
||||
expect(() => checkInputEmailIsValid(validEmail)).not.toThrow();
|
||||
});
|
||||
});
|
||||
describe("getOrgConnectionInfo", () => {
|
||||
const orgAutoAcceptDomain = "example.com";
|
||||
const usersEmail = "user@example.com";
|
||||
|
||||
@@ -46,6 +46,14 @@ export async function checkPermissions({
|
||||
}
|
||||
}
|
||||
|
||||
export function checkInputEmailIsValid(email: string) {
|
||||
if (!isEmail(email))
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invite failed because ${email} is not a valid email address`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
@@ -62,7 +70,7 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
|
||||
return team;
|
||||
}
|
||||
|
||||
export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
|
||||
export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) {
|
||||
const emailsToInvite = Array.isArray(usernameOrEmail)
|
||||
? Array.from(new Set(usernameOrEmail))
|
||||
: [usernameOrEmail];
|
||||
@@ -128,11 +136,11 @@ export function validateInviteeEligibility(
|
||||
}
|
||||
|
||||
export async function getUsersToInvite({
|
||||
usernameOrEmail,
|
||||
usernamesOrEmails,
|
||||
isInvitedToOrg,
|
||||
team,
|
||||
}: {
|
||||
usernameOrEmail: string[];
|
||||
usernamesOrEmails: string[];
|
||||
isInvitedToOrg: boolean;
|
||||
team: TeamWithParent;
|
||||
}): Promise<UserWithMembership[]> {
|
||||
@@ -149,7 +157,7 @@ export async function getUsersToInvite({
|
||||
|
||||
const invitees: UserWithMembership[] = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }],
|
||||
OR: [{ username: { in: usernamesOrEmails }, ...orgWhere }, { email: { in: usernamesOrEmails } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -217,46 +225,53 @@ export async function createNewUsersConnectToOrgIfExists({
|
||||
autoAcceptEmailDomain?: string;
|
||||
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
|
||||
}) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < usernamesOrEmails.length; index++) {
|
||||
const usernameOrEmail = usernamesOrEmails[index];
|
||||
const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail];
|
||||
const [emailUser, emailDomain] = usernameOrEmail.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
// fail if we have invalid emails
|
||||
usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail));
|
||||
|
||||
const createdUser = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: usernameOrEmail,
|
||||
verified: true,
|
||||
invitedTo: input.teamId,
|
||||
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
|
||||
teams: {
|
||||
create: {
|
||||
teamId: input.teamId,
|
||||
role: input.role as MembershipRole,
|
||||
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
|
||||
// from this point we know usernamesOrEmails contains only emails
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (let index = 0; index < usernamesOrEmails.length; index++) {
|
||||
const usernameOrEmail = usernamesOrEmails[index];
|
||||
const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail];
|
||||
const [emailUser, emailDomain] = usernameOrEmail.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
|
||||
const createdUser = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: usernameOrEmail,
|
||||
verified: true,
|
||||
invitedTo: input.teamId,
|
||||
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
|
||||
teams: {
|
||||
create: {
|
||||
teamId: input.teamId,
|
||||
role: input.role as MembershipRole,
|
||||
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// We also need to create the membership in the parent org if it exists
|
||||
if (parentId) {
|
||||
await tx.membership.create({
|
||||
data: {
|
||||
teamId: parentId,
|
||||
userId: createdUser.id,
|
||||
role: input.role as MembershipRole,
|
||||
accepted: autoAccept,
|
||||
},
|
||||
});
|
||||
|
||||
// We also need to create the membership in the parent org if it exists
|
||||
if (parentId) {
|
||||
await tx.membership.create({
|
||||
data: {
|
||||
teamId: parentId,
|
||||
userId: createdUser.id,
|
||||
role: input.role as MembershipRole,
|
||||
accepted: autoAccept,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createProvisionalMemberships({
|
||||
|
||||
@@ -64,3 +64,5 @@ export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByT
|
||||
|
||||
return verificationToken.team.name;
|
||||
};
|
||||
|
||||
export default inviteMemberByTokenHandler;
|
||||
|
||||
@@ -41,3 +41,5 @@ export const listHandler = async ({ ctx }: ListOptions) => {
|
||||
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`),
|
||||
}));
|
||||
};
|
||||
|
||||
export default listHandler;
|
||||
|
||||
@@ -18,3 +18,5 @@ export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default listInvitesHandler;
|
||||
|
||||
@@ -54,3 +54,5 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
|
||||
|
||||
return Object.values(users);
|
||||
};
|
||||
|
||||
export default listMembersHandler;
|
||||
|
||||
@@ -157,3 +157,5 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => {
|
||||
message: "Team published successfully",
|
||||
};
|
||||
};
|
||||
|
||||
export default publishHandler;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager";
|
||||
@@ -14,11 +15,16 @@ type RemoveMemberOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
sourceIp?: string;
|
||||
};
|
||||
input: TRemoveMemberInputSchema;
|
||||
};
|
||||
|
||||
export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: `removeMember.${ctx.sourceIp}`,
|
||||
});
|
||||
|
||||
const isAdmin = await isTeamAdmin(ctx.user.id, input.teamId);
|
||||
const isOrgAdmin = ctx.user.organizationId
|
||||
? await isTeamAdmin(ctx.user.id, ctx.user.organizationId)
|
||||
@@ -121,3 +127,5 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
|
||||
closeComDeleteTeamMembership(membership.user);
|
||||
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId);
|
||||
};
|
||||
|
||||
export default removeMemberHandler;
|
||||
|
||||
@@ -58,3 +58,5 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export default resendInvitationHandler;
|
||||
|
||||
@@ -39,3 +39,5 @@ export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpira
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default setInviteExpirationHandler;
|
||||
|
||||
@@ -97,3 +97,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
darkBrandColor: updatedTeam.darkBrandColor,
|
||||
};
|
||||
};
|
||||
|
||||
export default updateHandler;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user