Compare commits

..
24 Commits
Author SHA1 Message Date
Omar López 2e50625165 v3.5.4
release.yml / release (push) Canceled after 0s
2023-11-30 15:47:15 -07:00
sean-brydonandMorgan c78eb752a7 feat: rate limit removeMember (#12570)
* feat: rate limit removeMember

* Remove optional type as its always there

* fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-11-30 22:16:40 +00:00
Somay Chauhan 1c20bdcecf fix: rescheduling recurring events (#12567) 2023-11-30 13:27:37 -05:00
Amit Sharma 338756439f fix: meeting ended incorrect content type (#12484) 2023-11-30 17:52:55 +00:00
56050b994d fix 'attempt booking in the past' error (#12597)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-11-30 17:31:59 +00:00
sean-brydon c11ecbb323 Fix:use typed query initially fill default paramaters (#12568) 2023-11-29 17:51:47 -05:00
Peer RichelsenandOmar López 7f23ae156b fix: improved team upgrade screen to also show unpublished teams (#12492)
* improved team upgrade screen to also show unpublished teams

* Update TeamsListing.tsx

* bunch of stuff

---------

Co-authored-by: Omar López <zomars@me.com>
2023-11-29 14:48:26 -07:00
zomars 33250652b3 Double e2e shards 2023-11-29 14:40:21 -07:00
Morgan a65e18d92d chore: improve invitation form validation (#12594)
* chore: improve invitation form validation

* fixup! chore: improve invitation form validation

* fixup! fixup! chore: improve invitation form validation
2023-11-29 12:44:48 -07:00
Carina WollendorferandCarinaWolli d0f7085cb8 fix: updating workflow with new step and new active event type (#12592)
* correctly filter event types to avoid null values

* clean up all filters

* re-add reverted fix

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-11-29 23:51:12 +05:30
Morgan ea8437b4f7 fix: invite member by username (#12591)
* fix: invite member by username

* fixup! fix: invite member by username
2023-11-29 19:57:51 +02:00
Joe Au-YeungandOmar López 877cd4cdff refactor: Team Creation Flow [CAL-2751] (#12501)
* Create new endpoint for creating a team

* Generate a team checkout session

* Create team navigate to checkout

* Clean up

* UI changes

* Add comments

* Fix

* Type fix

* Type fix

* Type fix

* Type fixes

* Set telemetry

* Import fix

* Type fix

* Update tests

* Type fix

* fix: e2e

* fix: e2e

* fix: e2e

* fix: e2e

* Update teams.e2e.ts

* fix: e2e

---------

Co-authored-by: Omar López <zomars@me.com>
2023-11-29 09:39:21 -07:00
Igor Perzic bae3bd76e5 docs: update byte size for openssl command (#12587) 2023-11-29 15:15:04 +00:00
Joe Au-Yeung 8b24995d52 refactor: Abstract createBooking in handleNewBooking [CAL-2619] (#11959)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

In anticipation of refactoring `handleSeats` we need to abstract `createBooking` in order to get the return type. This PR is purposely aiming to do one thing so nothing is missed while refactoring `handleNewBooking`

Fixes # (issue)

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Requirement/Documentation

<!-- Please provide all documents that are important to understand the reason of that PR. -->

- If there is a requirement document, please, share it here.
- If there is ab UI/UX design document, please, share it here.

## Type of change

<!-- Please delete bullets that are not relevant. -->

- [x] Chore (refactoring code, technical debt, workflow improvements)


## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Mandatory Tasks

- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

## Checklist

<!-- Please remove all the irrelevant bullets to your PR -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings
- I haven't added tests that prove my fix is effective or that my feature works
- I haven't checked if new and existing unit tests pass locally with my changes
2023-11-29 08:11:09 -07:00
Udit Takkarandsean-brydon 7dc6df2ad6 chore: improve logs (#12467)
* chore: improve logs

* fix: import

* chore: use safe stringify

* chore: add more logs to google calendar

* chore: use this.log

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-11-29 19:39:25 +05:30
Peer RichelsenandUdit Takkar 6c4b1154b8 chore: removed last web3 references (#12540)
* removed last web3 references

* updated yarn

* Update schema.prisma

* readded icon

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-11-29 11:57:50 +00:00
Pratik KumarandUdit Takkar 1099bad930 fix:selecting behaviour fixed (#12578)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-11-29 11:53:31 +00:00
Crowdin Bot dbd5d27d9d New Crowdin translations by Github Action 2023-11-29 09:57:23 +00:00
Udit TakkarandMorgan 45c4f4789b chore: add check for recording (#12584)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-11-29 09:54:14 +00:00
Morgan 07f42057fc fix: increase maxWait/Timeout invite members transaction (#12583) 2023-11-29 09:07:03 +00:00
Crowdin Bot 79ee7c41b2 New Crowdin translations by Github Action 2023-11-29 08:03:18 +00:00
Joe Au-Yeung 57e6971942 chore: Refactor <CalendarListContainer /> (#12388)
* Import calendarlistcontainer in settings

* Change CalendarListComponent to Figma design

* Add border subtle

* Address feedback
2023-11-29 13:29:51 +05:30
Alex van Andel bd6ca21e02 fix: Toggling really fast throws internal errors (#12580) 2023-11-28 23:04:54 +00:00
Carina Wollendorfer f19a1926b4 chore: refactor reschedule EventManager with changed organizer (#12574)
## What does this PR do?

Follow up for https://github.com/calcom/cal.com/pull/12469

Event creation is now already handled in the reschedule function of EventManager. 

## Type of change

- [x] Chore (refactoring code, technical debt, workflow improvements)

## How should this be tested?

- Same as in https://github.com/calcom/cal.com/pull/12469
2023-11-28 11:37:31 -07:00
105 changed files with 1466 additions and 3303 deletions
+1 -1
View File
@@ -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=""
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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
+115 -105
View File
@@ -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 -2
View File
@@ -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": {
+92
View File
@@ -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) }),
});
+1 -1
View File
@@ -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,
+1
View File
@@ -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();
});
+8
View File
@@ -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");
}
+16 -8
View File
@@ -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();
+85 -82
View File
@@ -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",
+11 -5
View File
@@ -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;
}
}
+11 -3
View File
@@ -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],
};
}
+1 -1
View File
@@ -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
+339 -352
View File
@@ -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>
+2 -1
View File
@@ -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>
+7 -2
View File
@@ -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}
+6
View File
@@ -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) {
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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
+12 -1
View File
@@ -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);
+2 -1
View File
@@ -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;
+1
View File
@@ -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",
+7 -1
View File
@@ -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