This commit is contained in:
Peer Richelsen
2023-05-16 17:22:19 +01:00
182 changed files with 3307 additions and 1510 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+43
View File
@@ -0,0 +1,43 @@
tasks:
- init: |
yarn &&
cp .env.example .env &&
next_auth_secret=$(openssl rand -base64 32) &&
calendso_encryption_key=$(openssl rand -base64 24) &&
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
command: yarn dx
ports:
- port: 3000
visibility: public
onOpen: open-preview
- port: 5420
visibility: private
onOpen: ignore
- port: 1025
visibility: private
onOpen: ignore
- port: 8025
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- DavidAnson.vscode-markdownlint
- yzhang.markdown-all-in-one
- esbenp.prettier-vscode
- dbaeumer.vscode-eslint
- bradlc.vscode-tailwindcss
- ban.spellright
- stripe.vscode-stripe
- Prisma.prisma
File diff suppressed because one or more lines are too long
+2
View File
@@ -3,5 +3,7 @@ nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.4.1.cjs
+27 -19
View File
@@ -158,6 +158,15 @@ yarn dx
```sh
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
```
#### Gitpod Setup
1. Click the button below to open this project in Gitpod.
2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/calcom/cal.com)
#### Manual setup
@@ -166,34 +175,29 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
```
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
```
<details>
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB using Heroku.</summary>
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB</summary>
1. Create a free account with [Heroku](https://heroku.com).
1. [Download](https://www.postgresql.org/download/) and install postgres in your local (if you don't have it already).
2. Create a new app.
<img width="306" alt="Create an App" src="https://user-images.githubusercontent.com/16905768/115322780-b3d58c00-a17e-11eb-8a52-b758fb0ea942.png">
2. Create your own local db by executing `createDB <DB name>`
3. In your new app, go to `Overview` and next to `Installed add-ons`, click `Configure Add-ons`. We need this to set up our database.
![image](https://user-images.githubusercontent.com/16905768/115323232-a53ba480-a17f-11eb-98db-58e2f8c52426.png)
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
4. Once you clicked on `Configure Add-ons`, click on `Find more add-ons` and search for `postgres`. One of the options will be `Heroku Postgres` - click on that option.
![image](https://user-images.githubusercontent.com/16905768/115323126-5beb5500-a17f-11eb-8030-7380310807a9.png)
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png)
5. Once the pop-up appears, click `Submit Order Form` - plan name should be `Hobby Dev - Free`.
<img width="512" alt="Submit Order Form" src="https://user-images.githubusercontent.com/16905768/115323265-b4baed80-a17f-11eb-99f0-d67f019aa6df.png">
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
6. Once you completed the above steps, click on your newly created `Heroku Postgres` and go to its `Settings`.
![image](https://user-images.githubusercontent.com/16905768/115323367-e92ea980-a17f-11eb-9ff4-dec95f2ec349.png)
7. In `Settings`, copy your URI to your Cal.com `.env` file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
![image](https://user-images.githubusercontent.com/16905768/115323556-4591c900-a180-11eb-9808-2f55d2aa3995.png)
![image](https://user-images.githubusercontent.com/16905768/115323697-7a9e1b80-a180-11eb-9f08-a742b1037f90.png)
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com).
</details>
If you don't want to create a local DB. Then you can also consider using services like railway.app or render.
- [Setup postgres DB with railway.app](https://arctype.com/postgres/setup/railway-postgres)
- [Setup postgres DB with render](https://render.com/docs/databases)
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
1. Set a 32 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
@@ -460,6 +464,10 @@ following
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
### Obtaining Webex Client ID and Secret
[See Webex Readme](./packages/app-store/webex/)
### Obtaining ZohoCRM Client ID and Secret
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@calcom/docs",
"version": "1.0.0",
"private": true,
"description": "",
"main": "index.js",
"scripts": {
+10
View File
@@ -0,0 +1,10 @@
# @calcom/web
## 2.7.16
### Patch Changes
- Updated dependencies
- @calcom/embed-snippet@1.0.7
- @calcom/embed-react@1.0.12
- @calcom/embed-core@1.1.5
@@ -119,7 +119,7 @@ const EventTypeScheduleDetails = ({
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
@@ -390,7 +390,12 @@ export const EventSetupTab = (
addOnLeading={
<>
{CAL_URL?.replace(/^(https?:|)\/\//, "")}/
{team ? "team/" + team.slug : eventType.users[0].username}/
{!isManagedEventType
? team
? "team/" + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
@@ -170,7 +170,7 @@ export const EventTeamWebhooksTab = ({
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent title={t("edit_webhook")}>
<DialogContent enableOverflow title={t("edit_webhook")}>
<WebhookForm
webhook={webhookToEdit}
apps={installedApps?.items.map((app) => app.slug)}
@@ -170,7 +170,7 @@ function EventTypeSingleLayout({
// Define tab navigation here
const EventTypeTabs = useMemo(() => {
let navigation = getNavigation({
const navigation = getNavigation({
t,
eventType,
enabledAppsNumber,
@@ -210,7 +210,7 @@ function EventTypeSingleLayout({
}
if (isManagedEventType || isChildrenManagedEventType) {
// Removing apps and workflows for manageg event types by admins v1
navigation = navigation.slice(0, -2);
navigation.splice(-2, 1);
} else {
navigation.push({
name: "webhooks",
@@ -3,15 +3,16 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import { Button } from "@calcom/ui";
interface ICalendarItem {
interface IAppConnectionItem {
title: string;
description?: string;
logo: string;
type: App["type"];
installed?: boolean;
}
const CalendarItem = (props: ICalendarItem) => {
const { title, logo, type } = props;
const AppConnectionItem = (props: IAppConnectionItem) => {
const { title, logo, type, installed } = props;
const { t } = useLocale();
return (
<div className="flex flex-row items-center justify-between p-5">
@@ -25,13 +26,14 @@ const CalendarItem = (props: ICalendarItem) => {
<Button
{...buttonProps}
color="secondary"
disabled={installed}
type="button"
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
}}>
{t("connect")}
{installed ? t("installed") : t("connect")}
</Button>
)}
/>
@@ -39,4 +41,4 @@ const CalendarItem = (props: ICalendarItem) => {
);
};
export { CalendarItem };
export { AppConnectionItem };
@@ -0,0 +1,17 @@
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui";
export function StepConnectionLoader() {
return (
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
{Array.from({ length: 4 }).map((_item, index) => {
return (
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</li>
);
})}
</ul>
);
}
@@ -3,11 +3,12 @@ import { ArrowRightIcon } from "@heroicons/react/solid";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { SkeletonAvatar, SkeletonText, SkeletonButton, List } from "@calcom/ui";
import { List } from "@calcom/ui";
import { CalendarItem } from "../components/CalendarItem";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface IConnectCalendarsProps {
nextStep: () => void;
@@ -33,7 +34,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
firstCalendar.integration.title &&
firstCalendar.integration.logo && (
<>
<List className="bg-default border-subtle rounded-md border p-0 dark:bg-black">
<List className="bg-default border-subtle rounded-md border p-0 dark:bg-black ">
<ConnectedCalendarItem
key={firstCalendar.integration.title}
name={firstCalendar.integration.title}
@@ -60,7 +61,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
queryIntegrations.data.items.map((item) => (
<li key={item.title}>
{item.title && item.logo && (
<CalendarItem
<AppConnectionItem
type={item.type}
title={item.title}
description={item.description}
@@ -72,19 +73,8 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
</List>
)}
{queryIntegrations.isLoading && (
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
{[0, 0, 0, 0].map((_item, index) => {
return (
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</li>
);
})}
</ul>
)}
{queryIntegrations.isLoading && <StepConnectionLoader />}
<button
type="button"
data-testid="save-calendar-button"
@@ -0,0 +1,68 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { List } from "@calcom/ui";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface ConnectedAppStepProps {
nextStep: () => void;
}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep } = props;
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
});
const { t } = useLocale();
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.credentialIds.length > 0
);
return (
<>
{!isLoading && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
description={item.description}
logo={item.logo}
installed={item.credentialIds.length > 0}
/>
)}
</li>
);
})}
</List>
)}
{isLoading && <StepConnectionLoader />}
<button
type="button"
data-testid="save-video-button"
className={classNames(
"text-inverted mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm",
!hasAnyInstalledVideoApps ? "cursor-not-allowed opacity-20" : ""
)}
disabled={!hasAnyInstalledVideoApps}
onClick={() => nextStep()}>
{t("next_step_text")}
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</button>
</>
);
};
export { ConnectedVideoStep };
+1 -1
View File
@@ -31,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
{!isBioEmpty ? (
<>
<div
className=" text-subtle text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
/>
</>
@@ -230,7 +230,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<div className="absolute top-0 right-2 flex flex-row">
<span
className={classNames(
"mx-2 py-1",
"mx-2 py-2",
isInputUsernamePremium ? "text-orange-400" : "",
usernameIsAvailable ? "" : ""
)}>
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.8.12",
"version": "2.9.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -115,12 +115,11 @@
"react-select": "^5.7.0",
"react-timezone-select": "^1.4.0",
"react-use-intercom": "1.5.1",
"remark": "^14.0.2",
"remove-markdown": "^0.5.0",
"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
"short-uuid": "^4.2.0",
"strip-markdown": "^5.0.0",
"stripe": "^9.16.0",
"superjson": "1.9.1",
"tailwindcss-radix": "^2.6.0",
@@ -153,6 +152,7 @@
"@types/qrcode": "^1.4.3",
"@types/react": "18.0.26",
"@types/react-phone-number-input": "^3.0.14",
"@types/remove-markdown": "^0.3.1",
"@types/sanitize-html": "^2.9.0",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
+16 -6
View File
@@ -23,6 +23,7 @@ import defaultEvents, {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -37,7 +38,16 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
const {
users,
profile,
eventTypes,
isDynamicGroup,
dynamicNames,
dynamicUsernames,
isSingleUser,
markdownStrippedBio,
} = props;
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(user.theme);
const { t } = useLocale();
@@ -107,11 +117,9 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<>
<HeadSeo
title={isDynamicGroup ? dynamicNames.join(", ") : nameOrUsername}
description={
isDynamicGroup ? `Book events with ${dynamicUsernames.join(", ")}` : (user.bio as string) || ""
}
description={isDynamicGroup ? `Book events with ${dynamicUsernames.join(", ")}` : markdownStrippedBio}
meeting={{
title: isDynamicGroup ? "" : `${user.bio}`,
title: isDynamicGroup ? "" : markdownStrippedBio,
profile: { name: `${profile.name}`, image: null },
users: isDynamicGroup
? dynamicUsernames.map((username, index) => ({ username, name: dynamicNames[index] }))
@@ -136,7 +144,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
{!isBioEmpty && (
<>
<div
className=" text-subtle text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
@@ -342,6 +350,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");
return {
props: {
users,
@@ -361,6 +370,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
dynamicNames,
dynamicUsernames,
isSingleUser,
markdownStrippedBio,
},
};
};
+2 -1
View File
@@ -46,7 +46,8 @@ class MyDocument extends Document<Props> {
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" content="var(--cal-bg)" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
</Head>
<body
+62 -21
View File
@@ -1,3 +1,4 @@
import type { WebhookTriggerEvents } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
@@ -5,6 +6,8 @@ import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -20,6 +23,38 @@ const downloadLinkSchema = z.object({
download_link: z.string(),
});
const triggerWebhook = async ({
evt,
downloadLink,
booking,
}: {
evt: CalendarEvent;
downloadLink: string;
booking: {
userId: number | undefined;
eventTypeId: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const subscriberOptions = {
userId: booking.userId ?? 0,
eventTypeId: booking.eventTypeId ?? 0,
triggerEvent: eventTrigger,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
downloadLink,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
})
);
await Promise.all(promises);
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
@@ -51,6 +86,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
uid: true,
location: true,
isRecorded: true,
eventTypeId: true,
user: {
select: {
id: true,
@@ -105,31 +141,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
await triggerWebhook({
evt,
downloadLink,
booking: { userId: booking?.user?.id, eventTypeId: booking.eventTypeId },
});
const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;
// send emails to all attendees only when user has team plan
if (isSendingEmailsAllowed) {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
}
+1 -4
View File
@@ -4,7 +4,6 @@ import type { SatoriOptions } from "satori";
import { z } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { md } from "@calcom/lib/markdownIt";
const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) =>
res.arrayBuffer()
@@ -75,12 +74,10 @@ export default async function handler(req: NextApiRequest) {
imageType,
});
const title_ = md.render(title).replace(/(<([^>]+)>)/gi, "");
const img = new ImageResponse(
(
<Meeting
title={title_}
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
+47 -12
View File
@@ -2,6 +2,7 @@ import fs from "fs";
import matter from "gray-matter";
import MarkdownIt from "markdown-it";
import type { GetStaticPaths, GetStaticPropsContext } from "next";
import Link from "next/link";
import path from "path";
import { z } from "zod";
@@ -33,7 +34,26 @@ const sourceSchema = z.object({
}),
});
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
// If it's not production environment, it would be a better idea to inform that the App is disabled.
if (props.isAppDisabled) {
if (process.env.NODE_ENV !== "production") {
// TODO: Improve disabled App UI. This is just a placeholder.
return (
<div className="p-2">
This App seems to be disabled. If you are an admin, you can enable this app from{" "}
<Link href="/settings/admin/apps" className="cursor-pointer text-blue-500 underline">
here
</Link>
</div>
);
}
// Disabled App should give 404 any ways in production.
return null;
}
const { source, data } = props;
return (
<App
name={data.name}
@@ -80,29 +100,43 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true };
const app = await prisma.app.findUnique({
const appMeta = await getAppWithMetadata({
slug: ctx.params?.slug,
});
const appFromDb = await prisma.app.findUnique({
where: { slug: ctx.params.slug.toLowerCase() },
});
if (!app) return { notFound: true };
const isAppAvailableInFileSystem = appMeta;
const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled);
const singleApp = await getAppWithMetadata(app);
if (process.env.NODE_ENV !== "production" && isAppDisabled) {
return {
props: {
isAppDisabled: true as const,
data: {
...appMeta,
},
},
};
}
if (!singleApp) return { notFound: true };
if (!appFromDb || !appMeta || isAppDisabled) return { notFound: true };
const isTemplate = singleApp.isTemplate;
const appDirname = path.join(isTemplate ? "templates" : "", app.dirName);
const isTemplate = appMeta.isTemplate;
const appDirname = path.join(isTemplate ? "templates" : "", appFromDb.dirName);
const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/DESCRIPTION.md`);
const postFilePath = path.join(README_PATH);
let source = "";
try {
source = fs.readFileSync(postFilePath).toString();
source = source.replace(/{DESCRIPTION}/g, singleApp.description);
source = source.replace(/{DESCRIPTION}/g, appMeta.description);
} catch (error) {
/* If the app doesn't have a README we fallback to the package description */
console.log(`No DESCRIPTION.md provided for: ${appDirname}`);
source = singleApp.description;
source = appMeta.description;
}
const result = matter(source);
@@ -111,8 +145,8 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
data.items = data.items.map((item) => {
if (typeof item === "string") {
return getAppAssetFullPath(item, {
dirName: singleApp.dirName,
isTemplate: singleApp.isTemplate,
dirName: appMeta.dirName,
isTemplate: appMeta.isTemplate,
});
}
return item;
@@ -120,8 +154,9 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
}
return {
props: {
isAppDisabled: false as const,
source: { content, data },
data: singleApp,
data: appMeta,
},
};
};
+6 -4
View File
@@ -157,10 +157,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
let deploymentKey = await getDeploymentKey(prisma);
const deploymentKey = await prisma.deployment.findUnique({
where: { id: 1 },
select: { licenseKey: true },
});
// Check existant CALCOM_LICENSE_KEY env var and acccount for it
if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey) {
if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey?.licenseKey) {
await prisma.deployment.upsert({
where: { id: 1 },
update: {
@@ -172,10 +175,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
agreedLicenseAt: new Date(),
},
});
deploymentKey = await getDeploymentKey(prisma);
}
const isFreeLicense = deploymentKey === "";
const isFreeLicense = (await getDeploymentKey(prisma)) === "";
return {
props: {
+2 -2
View File
@@ -62,7 +62,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
<h3 className="text-emphasis font-medium leading-6">
{t("date_overrides")}{" "}
<Tooltip content={t("date_overrides_info")}>
<span className="inline-block">
<span className="inline-block align-middle">
<Info className="h-4 w-4" />
</span>
</Tooltip>
@@ -212,7 +212,7 @@ export default function Availability() {
aria-label={t("delete")}
className="hidden sm:inline"
disabled={schedule?.isLastSchedule}
tooltip={t("requires_at_least_one_schedule")}
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
/>
</DialogTrigger>
<ConfirmationDialogContent
+12 -10
View File
@@ -490,18 +490,20 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
as={DropdownItem}
type="button"
StartIcon={Code}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
</>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
<EmbedButton
as={DropdownItem}
type="button"
StartIcon={Code}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
!isChildrenManagedEventType && (
+42 -3
View File
@@ -15,6 +15,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
import UserProfile from "@components/getting-started/steps-views/UserProfile";
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
@@ -22,7 +23,13 @@ import { UserSettings } from "@components/getting-started/steps-views/UserSettin
export type IOnboardingPageProps = inferSSRProps<typeof getServerSideProps>;
const INITIAL_STEP = "user-settings";
const steps = ["user-settings", "connected-calendar", "setup-availability", "user-profile"] as const;
const steps = [
"user-settings",
"connected-calendar",
"connected-video",
"setup-availability",
"user-profile",
] as const;
const stepTransform = (step: (typeof steps)[number]) => {
const stepIndex = steps.indexOf(step);
@@ -36,9 +43,9 @@ const stepRouteSchema = z.object({
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
});
// TODO: Refactor how steps work to be contained in one array/object. Currently we have steps,initalsteps,headers etc. These can all be in one place
const OnboardingPage = (props: IOnboardingPageProps) => {
const router = useRouter();
const { user } = props;
const { t } = useLocale();
@@ -55,6 +62,11 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
subtitle: [`${t("connect_your_calendar_instructions")}`],
skipText: `${t("connect_calendar_later")}`,
},
{
title: `${t("connect_your_video_app")}`,
subtitle: [`${t("connect_your_video_app_instructions")}`],
skipText: `${t("set_up_later")}`,
},
{
title: `${t("set_availability")}`,
subtitle: [
@@ -68,6 +80,17 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
},
];
// TODO: Add this in when we have solved the ability to move to tokens accept invite and note invitedto
// Ability to accept other pending invites if any (low priority)
// if (props.hasPendingInvites) {
// headers.unshift(
// props.hasPendingInvites && {
// title: `${t("email_no_user_invite_heading", { appName: APP_NAME })}`,
// subtitle: [], // TODO: come up with some subtitle text here
// }
// );
// }
const goToIndex = (index: number) => {
const newStep = steps[index];
router.push(
@@ -122,12 +145,15 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={() => goToIndex(3)} defaultScheduleId={user.defaultScheduleId} />
<SetupAvailability nextStep={() => goToIndex(4)} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile user={user} />}
</StepCard>
{headers[currentStepIndex]?.skipText && (
<div className="flex w-full flex-row justify-center">
<Button
@@ -181,6 +207,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
allowDynamicBooking: true,
defaultScheduleId: true,
completedOnboarding: true,
teams: {
select: {
accepted: true,
team: {
select: {
id: true,
name: true,
logo: true,
},
},
},
},
},
});
@@ -199,6 +237,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
},
};
};
+13 -6
View File
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getUsernameList } from "@calcom/lib/defaultEvents";
@@ -16,6 +17,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo username={user} eventSlug={slug} rescheduleUid={booking?.uid} />
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
@@ -27,8 +29,8 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { user, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const usernameList = getUsernameList(user);
const users = await prisma.user.findMany({
@@ -53,13 +55,15 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
await ssr.viewer.public.event.prefetch({ username: user, eventSlug: slug });
return {
props: {
booking,
user,
slug,
away: false,
trpcState: ssg.dehydrate(),
trpcState: ssr.dehydrate(),
},
};
}
@@ -67,8 +71,9 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: username, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findUnique({
where: {
username,
@@ -89,13 +94,15 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
await ssr.viewer.public.event.prefetch({ username, eventSlug: slug });
return {
props: {
booking,
away: user?.away,
user: username,
slug,
trpcState: ssg.dehydrate(),
trpcState: ssr.dehydrate(),
},
};
}
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import prisma from "@calcom/prisma";
@@ -15,6 +16,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo username={user} eventSlug={slug} rescheduleUid={booking?.uid} />
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
@@ -30,8 +32,8 @@ const paramsSchema = z.object({ type: z.string(), slug: z.string() });
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const team = await prisma.team.findFirst({
where: {
@@ -52,14 +54,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
await ssr.viewer.public.event.prefetch({ username: teamSlug, eventSlug: meetingSlug });
return {
props: {
booking,
away: false,
user: teamSlug,
slug: meetingSlug,
trpcState: ssg.dehydrate(),
trpcState: ssr.dehydrate(),
},
};
};
+40 -28
View File
@@ -4,10 +4,12 @@ import { useRouter } from "next/router";
import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -16,7 +18,6 @@ import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@c
import PageWrapper from "@components/PageWrapper";
import { asStringOrNull } from "../lib/asStringOrNull";
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
import { ssrInit } from "../server/lib/ssr";
@@ -24,11 +25,10 @@ type FormValues = {
username: string;
email: string;
password: string;
passwordcheck: string;
apiError: string;
};
export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof getServerSideProps>) {
export default function Signup({ prepopulateFormValues, token }: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
@@ -76,7 +76,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
return (
<LicenseRequired>
<div
className="bg-muted flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8"
className="bg-muted flex min-h-screen flex-col justify-center "
style={
{
"--cal-brand": "#111827",
@@ -95,7 +95,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default mx-2 px-4 py-8 shadow sm:rounded-lg sm:px-10">
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
<FormProvider {...methods}>
<form
onSubmit={(event) => {
@@ -109,7 +109,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
}}
className="bg-default space-y-6">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
<div className="space-y-2">
<div className="space-y-4">
<TextField
addOnLeading={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/`}
{...register("username")}
@@ -125,32 +125,28 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
className: "block text-sm font-medium text-default",
}}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
/>
<PasswordField
label={t("confirm_password")}
{...register("passwordcheck", {
validate: (value) =>
value === methods.watch("password") || (t("error_password_mismatch") as string),
})}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button type="submit" loading={isSubmitting} className="w-7/12 justify-center">
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
{t("create_account")}
</Button>
<Button
color="secondary"
className="w-5/12 justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: router.query.callbackUrl
? `${WEBAPP_URL}/${router.query.callbackUrl}`
: `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
</Button>
{!token && (
<Button
color="secondary"
className="w-full justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: router.query.callbackUrl
? `${WEBAPP_URL}/${router.query.callbackUrl}`
: `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
</Button>
)}
</div>
</form>
</FormProvider>
@@ -163,7 +159,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const ssr = await ssrInit(ctx);
const token = asStringOrNull(ctx.query.token);
const token = z.string().optional().parse(ctx.query.token);
const props = {
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
@@ -221,11 +217,27 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
};
}
const guessUsernameFromEmail = (email: string) => {
const [username] = email.split("@");
return username;
};
let username = guessUsernameFromEmail(verificationToken.identifier);
if (!IS_SELF_HOSTED) {
// Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :)
const { available, suggestion } = await checkPremiumUsername(username);
username = available ? username : suggestion || username;
}
return {
props: {
...props,
token,
prepopulateFormValues: {
email: verificationToken.identifier,
username,
},
},
};
+13 -4
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -12,6 +12,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui";
@@ -26,7 +27,7 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team, isUnpublished }: TeamPageProps) {
function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
useTheme(team.theme);
const showMembers = useToggleQuery("members");
const { t } = useLocale();
@@ -67,6 +68,11 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<div className="px-6 py-4 ">
<Link
href={`/team/${team.slug}/${type.slug}`}
onClick={async () => {
sdkActionManager?.fire("eventTypeSelected", {
eventType: type,
});
}}
data-testid="event-type-link"
className="flex justify-between">
<div className="flex-shrink">
@@ -100,7 +106,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
title={teamName}
description={teamName}
meeting={{
title: team?.bio || "",
title: markdownStrippedBio,
profile: { name: `${team.name}`, image: getPlaceholderAvatar(team.logo, team.name) },
}}
/>
@@ -111,7 +117,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
{!isBioEmpty && (
<>
<div
className=" text-subtle text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
@@ -196,10 +202,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { ...member, safeBio: markdownToSafeHTML(member.bio || "") };
});
const markdownStrippedBio = stripMarkdown(team?.bio || "");
return {
props: {
team: { ...team, safeBio, members },
trpcState: ssr.dehydrate(),
markdownStrippedBio,
},
} as const;
};
+11 -2
View File
@@ -47,10 +47,19 @@ test.describe("Onboarding", () => {
// tests skip button, we don't want to test entire flow.
await page.locator("button[data-testid=skip-step]").click();
await expect(page).toHaveURL(/.*setup-availability/);
await expect(page).toHaveURL(/.*connected-video/);
});
await test.step("step 3", async () => {
const isDisabled = await page.locator("button[data-testid=save-video-button]").isDisabled();
await expect(isDisabled).toBe(true);
// tests skip button, we don't want to test entire flow.
await page.locator("button[data-testid=skip-step]").click();
await expect(page).toHaveURL(/.*setup-availability/);
});
await test.step("step 4", async () => {
const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled();
await expect(isDisabled).toBe(false);
// same here, skip this step.
@@ -59,7 +68,7 @@ test.describe("Onboarding", () => {
await expect(page).toHaveURL(/.*user-profile/);
});
await test.step("step 4", async () => {
await test.step("step 5", async () => {
await page.locator("button[type=submit]").click();
// should redirect to /event-types after onboarding
@@ -1724,7 +1724,6 @@
"locked_apps_description": "سيكون الأعضاء قادرين على رؤية التطبيقات النشطة، ولكن من دون القدرة على تعديل أي إعدادات للتطبيق",
"locked_webhooks_description": "سيكون الأعضاء قادرين على رؤية قوالب الويب النشطة، ولكن من دون القدرة على تعديل أي إعدادات للقوالب",
"locked_workflows_description": "سيكون الأعضاء قادرين على رؤية مسارات العمل النشطة، ولكن من دون القدرة على تعديل أي إعدادات لمسار العمل",
"locked_by_admin": "مقفل من قبل المشرف",
"app_not_connected": "أنت غير متصل بحساب {{appName}}.",
"connect_now": "اتصل الآن",
"managed_event_dialog_confirm_button_one": "استبدال وإشعار العضو {{count}}",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Členové budou vidět aktivní aplikace, ale nastavení aplikací upravovat moci nebudou",
"locked_webhooks_description": "Členové budou vidět aktivní webhooky, ale nastavení webhooků upravovat moci nebudou",
"locked_workflows_description": "Členové budou vidět aktivní pracovní postupy, ale nastavení pracovních postupů upravovat moci nebudou",
"locked_by_admin": "Uzamčeno správcem",
"app_not_connected": "Nemáte připojený účet {{appName}}.",
"connect_now": "Připojte se",
"managed_event_dialog_confirm_button_one": "Nahradit a upozornit {{count}} člena",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Mitglieder können die aktiven Apps sehen, können aber keine App-Einstellungen anpassen",
"locked_webhooks_description": "Mitglieder können die aktiven Webhooks sehen, können aber keine Webhook-Einstellungen anpassen",
"locked_workflows_description": "Mitglieder können die aktiven Workflows sehen, können aber keine Workflow-Einstellungen anpassen",
"locked_by_admin": "Von Admin gesperrt",
"app_not_connected": "Sie haben kein {{appName}}-Konto verbunden.",
"connect_now": "Jetzt verbinden",
"managed_event_dialog_confirm_button_one": "{{count}} Mitglied ersetzen & benachrichtigen",
@@ -237,6 +237,8 @@
"set_availability": "Set your availability",
"continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar",
"connect_your_video_app":"Connect your video apps",
"connect_your_video_app_instructions":"Connect your video apps to use them on your event types.",
"connect_your_calendar_instructions": "Connect your calendar to automatically check for busy times and new events as theyre scheduled.",
"set_up_later": "Set up later",
"current_time": "Current time",
@@ -367,6 +369,7 @@
"create_webhook": "Create Webhook",
"booking_cancelled": "Booking Cancelled",
"booking_rescheduled": "Booking Rescheduled",
"recording_ready":"Recording Download Link Ready",
"booking_created": "Booking Created",
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
@@ -1192,6 +1195,7 @@
"create_workflow": "Create a workflow",
"do_this": "Do this",
"turn_off": "Turn off",
"turn_on": "Turn on",
"settings_updated_successfully": "Settings updated successfully",
"error_updating_settings": "Error updating settings",
"personal_cal_url": "My personal {{appName}} URL",
@@ -1623,6 +1627,7 @@
"email_user_cta": "View Invitation",
"email_no_user_invite_heading": "Youve been invited to join a team on {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_no_user_invite_steps_intro": "Well walk you through a few short steps and youll be enjoying stress free scheduling with your team in no time.",
"email_no_user_step_one": "Choose your username",
"email_no_user_step_two": "Connect your calendar account",
@@ -1736,7 +1741,7 @@
"locked_apps_description": "Members will be able to see the active apps but will not be able to edit any app settings",
"locked_webhooks_description": "Members will be able to see the active webhooks but will not be able to edit any webhook settings",
"locked_workflows_description": "Members will be able to see the active workflows but will not be able to edit any workflow settings",
"locked_by_admin": "Locked by admin",
"locked_by_admin": "Locked by team admin",
"app_not_connected": "You have not connected a {{appName}} account.",
"connect_now": "Connect now",
"managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member",
@@ -1810,6 +1815,7 @@
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"email_not_cal_member_cta": "Join your team",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
"disable_host_confirmation_emails": "Disable default confirmation emails for host",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Los miembros podrán ver las aplicaciones activas pero no podrán editar ninguna configuración de la aplicación",
"locked_webhooks_description": "Los miembros podrán ver los webhooks activos, pero no podrán editar ninguna configuración de webhooks",
"locked_workflows_description": "Los miembros podrán ver los flujos de trabajo activos, pero no podrán editar ninguna configuración de flujo de trabajo",
"locked_by_admin": "Bloqueado por el administrador",
"app_not_connected": "No ha conectado una cuenta de {{appName}}.",
"connect_now": "Conectar ahora",
"managed_event_dialog_confirm_button_one": "Reemplazar y notificar a {{count}} miembro",
@@ -237,6 +237,8 @@
"set_availability": "Définissez vos disponibilités",
"continue_without_calendar": "Continuer sans calendrier",
"connect_your_calendar": "Connectez votre calendrier",
"connect_your_video_app": "Connectez vos applications vidéo",
"connect_your_video_app_instructions": "Connectez vos applications vidéo pour les utiliser sur vos types d'événements.",
"connect_your_calendar_instructions": "Connectez votre calendrier pour vérifier automatiquement vos disponibilités ainsi que les nouveaux événements lorsqu'ils sont planifiés.",
"set_up_later": "Configurer plus tard",
"current_time": "Heure actuelle",
@@ -367,6 +369,7 @@
"create_webhook": "Créer un Webhook",
"booking_cancelled": "Réservation annulée",
"booking_rescheduled": "Réservation replanifiée",
"recording_ready": "Lien de téléchargement d'enregistrement prêt",
"booking_created": "Réservation créée",
"meeting_ended": "Rendez-vous terminé",
"form_submitted": "Formulaire envoyé",
@@ -1192,6 +1195,7 @@
"create_workflow": "Créer un workflow",
"do_this": "Effectuer ceci",
"turn_off": "Désactiver",
"turn_on": "Activer",
"settings_updated_successfully": "Paramètres mis à jour avec succès",
"error_updating_settings": "Erreur lors de la mise à jour des paramètres",
"personal_cal_url": "Mon lien {{appName}} personnel",
@@ -1623,6 +1627,7 @@
"email_user_cta": "Voir l'invitation",
"email_no_user_invite_heading": "Vous avez été invité(e) à rejoindre une équipe sur {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} a été invité à rejoindre son équipe sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet, à vous et à votre équipe, de planifier des rendez-vous sans les allers-retours par e-mail.",
"email_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son équipe « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet, à vous et à votre équipe, de planifier des réunions sans les allers-retours par e-mail.",
"email_no_user_invite_steps_intro": "Nous vous guiderons à travers quelques étapes courtes et vous profiterez d'une planification sans stress avec votre équipe en un rien de temps.",
"email_no_user_step_one": "Choisissez votre nom d'utilisateur",
"email_no_user_step_two": "Connectez votre compte de calendrier",
@@ -1736,7 +1741,7 @@
"locked_apps_description": "Les membres pourront voir les applications actives, mais ne pourront pas modifier leurs paramètres.",
"locked_webhooks_description": "Les membres pourront voir les webhooks actifs, mais ne pourront pas modifier leurs paramètres.",
"locked_workflows_description": "Les membres pourront voir les workflows actifs, mais ne pourront pas modifier leurs paramètres.",
"locked_by_admin": "Verrouillé par l'administrateur",
"locked_by_admin": "Verrouillé par l'administrateur de l'équipe",
"app_not_connected": "Vous n'avez pas connecté de compte {{appName}}.",
"connect_now": "Connecter maintenant",
"managed_event_dialog_confirm_button_one": "Remplacer et notifier {{count}} membre",
@@ -1805,6 +1810,7 @@
"inline_embed": "Intégration en ligne",
"load_inline_content": "Charge votre type d'événement directement en ligne avec le contenu de votre site web.",
"floating_pop_up_button": "Bouton pop-up flottant",
"email_not_cal_member_cta": "Rejoignez votre équipe",
"disable_attendees_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour les participants",
"disable_attendees_confirmation_emails_description": "Au moins un workflow est actif sur ce type d'événement qui envoie un e-mail aux participants lorsque l'événement est réservé.",
"disable_host_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour l'hôte",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "I membri saranno in grado di vedere le app attive, ma non saranno in grado di modificare le impostazioni delle app",
"locked_webhooks_description": "I membri saranno in grado di vedere i webhook attivi, ma non saranno in grado di modificare le impostazioni dei webhook",
"locked_workflows_description": "I membri saranno in grado di vedere i flussi di lavoro attivi, ma non saranno in grado di modificare le impostazioni dei flussi di lavoro",
"locked_by_admin": "Bloccato dall'amministratore",
"app_not_connected": "Non è stato connesso un account di {{appName}}.",
"connect_now": "Connetti ora",
"managed_event_dialog_confirm_button_one": "Sostituisci e invia notifica a {{count}} membro",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "メンバーは有効なアプリを確認できますが、アプリの設定を編集することはできません",
"locked_webhooks_description": "メンバーは有効なウェブフックを確認できますが、ウェブフックの設定を編集することはできません",
"locked_workflows_description": "メンバーは有効なワークフローを確認できますが、ワークフローの設定を編集することはできません",
"locked_by_admin": "管理者によりロックされています",
"app_not_connected": "{{appName}} のアカウントに接続していません。",
"connect_now": "今すぐ接続",
"managed_event_dialog_confirm_button_one": "{{count}} 人のメンバーを置き換えて通知する",
+24 -2
View File
@@ -237,6 +237,7 @@
"set_availability": "언제 여유가 있는지 설정하세요.",
"continue_without_calendar": "캘린더 없이 계속하기",
"connect_your_calendar": "캘린더 연결하기",
"connect_your_video_app_instructions": "이벤트 타입에서 사용하기 위한 비디오 앱을 연결해주세요.",
"connect_your_calendar_instructions": "바쁜 시간과 새 이벤트 일정을 자동으로 확인할 수 있도록 캘린더 연결",
"set_up_later": "나중에 하기",
"current_time": "현재 시각",
@@ -465,6 +466,7 @@
"friday": "금요일",
"saturday": "토요일",
"sunday": "일요일",
"all_booked_today": "모두 예약됨.",
"slots_load_fail": "사용 가능한 시간 슬롯을 로드할 수 없습니다.",
"additional_guests": "게스트 추가",
"your_name": "당신의 이름",
@@ -1038,6 +1040,7 @@
"event_cancelled_trigger": "이벤트 취소 시",
"new_event_trigger": "새 이벤트 예약 시",
"email_host_action": "호스트에게 이메일 발송",
"email_attendee_action": "참석자들에게 이메일 전송",
"sms_attendee_action": "참석자에게 SMS 발송",
"sms_number_action": "특정 번호로 SMS 발송",
"workflows": "워크플로",
@@ -1246,6 +1249,7 @@
"calendars_description": "이벤트 타입이 캘린더와 상호 작용하는 방식 구성",
"appearance_description": "예약 모양 설정 관리",
"conferencing_description": "회의에 즐겨 사용하는 화상 회의 앱 추가",
"add_conferencing_app": "회의 앱 추가",
"password_description": "계정 비밀번호 설정 관리",
"2fa_description": "계정 비밀번호 설정 관리",
"we_just_need_basic_info": "프로필 설정을 위해 몇 가지 기본 정보만 있으면 됩니다.",
@@ -1620,6 +1624,7 @@
"email_user_cta": "초대 보기",
"email_no_user_invite_heading": "{{appName}} 팀에 초대되었습니다",
"email_no_user_invite_subheading": "{{invitedBy}} 님이 귀하를 {{appName}}에서 자신의 팀에 초대했습니다. {{appName}} 앱은 귀하와 귀하의 팀이 이메일을 주고 받지 않고 회의 일정을 잡을 수 있게 해주는 이벤트 정리 스케줄러입니다.",
"email_user_invite_subheading": "{{invitedBy}}님이 {{appName}}에서 자신의 `{{teamName}}` 팀에 가입하도록 당신을 초대했습니다. {{appName}}은 유저와 팀이 이메일을 주고 받지 않고도 회의 일정을 잡을 수 있게 하는 이벤트 조율 스케줄러입니다.",
"email_no_user_invite_steps_intro": "안내해 드릴 몇 가지 간단한 단계만 거치면 곧 팀과 함께 스트레스 없이 편리하게 일정을 관리할 수 있게 될 것입니다.",
"email_no_user_step_one": "사용자 이름 선택",
"email_no_user_step_two": "캘린더 계정 연결",
@@ -1695,6 +1700,14 @@
"spot_popular_event_types_description": "어떤 이벤트 타입이 가장 많은 클릭을 받고 예약이 되고 있는지 확인해보세요",
"no_responses_yet": "아직 응답 없음",
"this_will_be_the_placeholder": "이것은 자리 표시자가 됩니다",
"error_booking_event": "이벤트를 예약하는 도중 오류가 발생했습니다. 페이지를 새로고침한 후 다시 시도해주세요.",
"timeslot_missing_title": "시간대가 선택되지 않았습니다",
"timeslot_missing_description": "이벤트를 예약 할 시간대를 선택해주세요.",
"timeslot_missing_cta": "시간대 선택",
"switch_monthly": "월별 보기로 전환",
"switch_weekly": "주간 보기로 전환",
"switch_multiday": "일간 보기로 전환",
"num_locations": "{{num}}개의 위치 옵션",
"this_meeting_has_not_started_yet": "이 회의는 아직 시작되지 않았습니다",
"this_app_requires_connected_account": "{{appName}}에 연결된 {{dependencyName}} 계정이 필요합니다",
"connect_app": "{{dependencyName}} 연결",
@@ -1724,7 +1737,6 @@
"locked_apps_description": "회원은 활성 앱을 볼 수 있지만 앱 설정을 편집할 수는 없습니다",
"locked_webhooks_description": "회원은 활성 웹훅을 볼 수 있지만 웹훅 설정을 편집할 수는 없습니다",
"locked_workflows_description": "회원은 활성 워크플로를 볼 수 있지만 워크플로 설정을 편집할 수는 없습니다",
"locked_by_admin": "관리자에 의해 잠금 됨",
"app_not_connected": "{{appName}} 계정을 연결하지 않으셨습니다.",
"connect_now": "지금 연결",
"managed_event_dialog_confirm_button_one": "회원 {{count}}명 바꾸기 및 알림",
@@ -1782,5 +1794,15 @@
"seats_and_no_show_fee_error": "현재 좌석을 활성화할 수 없으므로 노쇼 수수료를 부과할 수 없습니다",
"complete_your_booking": "예약을 완료하세요",
"complete_your_booking_subject": "예약 완료: {{title}} 날짜 {{date}}",
"email_invite_team": "{{email}} 이 초대되었습니다."
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "참석자에게 {{amount, currency}}을 청구하려고 합니다. 계속 진행하시겠습니까?",
"charge_attendee": "참석자에게 {{amount, currency}}을 청구합니다.",
"payment_app_commission": "결제가 필요합니다 (건당 수수료 {{paymentFeePercentage}}% + {{fee, currency}})",
"email_invite_team": "{{email}} 이 초대되었습니다.",
"image_size_limit_exceed": "업로드되는 이미지의 크기는 5MB를 초과하면 안 됩니다.",
"need_help_embedding": "도움이 필요하신가요? Wix, Squarespace, 또는 WordPress에서 Cal을 임베드하는 가이드를 참조하거나, 자주 묻는 질문을 확인하거나, 고급 임베드 옵션을 살펴보세요.",
"disable_attendees_confirmation_emails": "참석자에 대한 기본 확인 이메일 비활성화",
"disable_attendees_confirmation_emails_description": "이 이벤트 유형에는 이벤트가 예약되었을 때 참석자들에게 이메일을 보내는 최소한 하나의 워크플로가 활성화되어 있습니다.",
"disable_host_confirmation_emails": "호스트에 대한 기본 확인 이메일 비활성화",
"disable_host_confirmation_emails_description": "이 이벤트 유형에는 이벤트가 예약될 때 호스트에게 이메일을 보내는 최소한 하나의 워크플로가 활성화되어 있습니다."
}
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Leden kunnen de actieve apps zien, maar kunnen geen appinstellingen bewerken",
"locked_webhooks_description": "Leden kunnen de actieve webhooks zien, maar kunnen geen webhookinstellingen bewerken",
"locked_workflows_description": "Leden kunnen de actieve werkstromen zien, maar kunnen geen werkstroominstellingen bewerken",
"locked_by_admin": "Vergrendeld door beheerder",
"app_not_connected": "U heeft geen {{appName}}-account gekoppeld.",
"connect_now": "Nu koppelen",
"managed_event_dialog_confirm_button_one": "{{count}} lid vervangen en informeren",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Członkowie będą mogli zobaczyć aktywne aplikacje, ale nie będą mogli zmieniać ich ustawień.",
"locked_webhooks_description": "Członkowie będą mogli zobaczyć aktywne webhooki, ale nie będą mogli zmieniać ich ustawień.",
"locked_workflows_description": "Członkowie będą mogli zobaczyć aktywne przepływy pracy, ale nie będą mogli zmieniać ich ustawień.",
"locked_by_admin": "Zablokowane przez administratora",
"app_not_connected": "Nie połączono konta aplikacji {{appName}}.",
"connect_now": "Połącz teraz",
"managed_event_dialog_confirm_button_one": "Zastąp i poinformuj {{count}} członka",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Os membros poderão ver os aplicativos ativos, mas não poderão editar as configurações",
"locked_webhooks_description": "Os membros poderão ver os webhooks ativos, mas não poderão editar as configurações",
"locked_workflows_description": "Os membros poderão ver os fluxos de trabalho ativos, mas não poderão editar as configurações",
"locked_by_admin": "Bloqueado pelo administrador",
"app_not_connected": "Você não conectou uma conta do {{appName}}.",
"connect_now": "Conectar agora",
"managed_event_dialog_confirm_button_one": "Substituir e notificar {{count}} membro",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Os membros poderão ver as aplicações ativas, mas não poderão editar quaisquer configurações das aplicações",
"locked_webhooks_description": "Os membros poderão ver os webhooks ativos, mas não poderão editar quaisquer configurações dos webhooks",
"locked_workflows_description": "Os membros poderão ver os fluxos de trabalho ativos, mas não poderão editar quaisquer configurações dos fluxos de trabalho",
"locked_by_admin": "Bloqueado pelo administrador",
"app_not_connected": "Não associou uma conta {{appName}}.",
"connect_now": "Associar agora",
"managed_event_dialog_confirm_button_one": "Substituir e notificar {{count}} membro",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Membrii vor putea vedea aplicațiile active, însă nu le vor putea modifica deloc setările",
"locked_webhooks_description": "Membrii vor putea vedea webhook-urile active, însă nu le vor putea modifica deloc setările",
"locked_workflows_description": "Membrii vor putea vedea fluxurile de lucru active, însă nu le vor putea modifica deloc setările",
"locked_by_admin": "Blocat de administrator",
"app_not_connected": "Nu ați conectat un cont {{appName}}.",
"connect_now": "Conectați-l acum",
"managed_event_dialog_confirm_button_one": "Înlocuiți și anunțați {{count}} membru",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Участники смогут видеть активные приложения, но не смогут редактировать их настройки",
"locked_webhooks_description": "Участники смогут видеть активные вебхуки, но не смогут редактировать их настройки",
"locked_workflows_description": "Участники смогут видеть активные рабочие процессы, но не смогут редактировать их настройки",
"locked_by_admin": "Заблокировано администратором",
"app_not_connected": "Вы не подключили аккаунт {{appName}}.",
"connect_now": "Подключить сейчас",
"managed_event_dialog_confirm_button_one": "Заменить и уведомить {{count}} участника",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Članovi će moći da vide aktivne aplikacije, ali neće moći da uređuju bilo kakva podešavanja aplikacije",
"locked_webhooks_description": "Članovi će moći da vide aktivne webhook-ove, ali neće moći da uređuju bilo kakva podešavanja webhook-ova",
"locked_workflows_description": "Članovi će moći da vide aktivne radne tokove, ali neće moći da uređuju bilo kakva podešavanja radnih tokova",
"locked_by_admin": "Zaključao administrator",
"app_not_connected": "Niste povezali {{appName}} nalog.",
"connect_now": "Povežite odmah",
"managed_event_dialog_confirm_button_one": "Zameni i obavesti {{count}} člana",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Medlemmarna kommer att kunna se de aktiva apparna, men de kommer inte att kunna ändra appinställningarna",
"locked_webhooks_description": "Medlemmarna kommer att kunna se aktiva webhooks men inte kunna redigera inställningar för webhooks",
"locked_workflows_description": "Medlemmarna kommer att kunna se de aktiva arbetsflödena, men de kommer inte att kunna ändra några arbetsflödesinställningar",
"locked_by_admin": "Låst av administratör",
"app_not_connected": "Du har inte anslutit ett {{appName}}-konto.",
"connect_now": "Anslut nu",
"managed_event_dialog_confirm_button_one": "Ersätt och meddela {{count}} medlem",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Üyeler aktif uygulamaları görebilir, ancak hiçbir uygulama ayarını düzenleyemez",
"locked_webhooks_description": "Üyeler aktif web kancalarını görebilir, ancak hiçbir web kancası ayarını düzenleyemez",
"locked_workflows_description": "Üyeler aktif iş akışlarını görebilir, ancak hiçbir iş akışı ayarını düzenleyemez",
"locked_by_admin": "Yönetici tarafından kilitlendi",
"app_not_connected": "Bir {{appName}} hesabı bağlamadınız.",
"connect_now": "Hemen bağlan",
"managed_event_dialog_confirm_button_one": "{{count}} üyeyi değiştirin ve bilgilendirin",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Учасники зможуть бачити активні додатки, але не зможуть редагувати налаштування додатка",
"locked_webhooks_description": "Учасники бачитимуть активні вебгуки, але не зможуть змінювати їх налаштування",
"locked_workflows_description": "Учасники бачитимуть активні робочі процеси, але не зможуть змінювати їх налаштування",
"locked_by_admin": "Заблоковано адміністратором",
"app_not_connected": "Ви не під’єднали обліковий запис {{appName}}.",
"connect_now": "Під’єднати",
"managed_event_dialog_confirm_button_one": "Замінити й повідомити {{count}} учасника",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "Các thành viên có thể thấy được những ứng dụng đang hoạt động nhưng không thể sửa bất kỳ thiết lập nào của ứng dụng",
"locked_webhooks_description": "Các thành viên có thể thấy được những webhook đang hoạt động nhưng không thể sửa bất kỳ thiết lập nào của webhook",
"locked_workflows_description": "Các thành viên có thể thấy được những tiến độ công việc đang hoạt động nhưng không thể sửa bất kỳ thiết lập nào của tiến độ công việc",
"locked_by_admin": "Bị khoá bởi quản trị viên",
"app_not_connected": "Bạn không có tài khoản {{appName}} đã được kết nối.",
"connect_now": "Kết nối ngay",
"managed_event_dialog_confirm_button_one": "Thay thế & thông báo cho {{count}} thành viên",
@@ -1191,6 +1191,7 @@
"create_workflow": "创建工作流程",
"do_this": "执行此操作",
"turn_off": "关闭",
"turn_on": "打开",
"settings_updated_successfully": "设置已成功更新",
"error_updating_settings": "更新设置时出错",
"personal_cal_url": "我的个人 {{appName}} URL",
@@ -1726,7 +1727,7 @@
"locked_apps_description": "成员将能够查看活动的应用,但不能编辑任何应用设置",
"locked_webhooks_description": "成员将能够查看活动的 Webhook,但不能编辑任何 Webhook 设置",
"locked_workflows_description": "成员将能够查看活动的工作流程,但不能编辑任何工作流程设置",
"locked_by_admin": "被管理员锁定",
"locked_by_admin": "被团队管理员锁定",
"app_not_connected": "您尚未连接 {{appName}} 账户。",
"connect_now": "立即连接",
"managed_event_dialog_confirm_button_one": "替换并通知 {{count}} 个成员",
@@ -1724,7 +1724,6 @@
"locked_apps_description": "成員將能查看啟用的應用程式,但無法編輯任何應用程式設定",
"locked_webhooks_description": "成員將能查看啟用的 Webhook,但無法編輯任何 Webhook 設定",
"locked_workflows_description": "成員將能查看啟用的工作流程,但無法編輯任何工作流程設定",
"locked_by_admin": "已由管理員鎖定",
"app_not_connected": "您尚未連結 {{appName}} 帳號。",
"connect_now": "立即連結",
"managed_event_dialog_confirm_button_one": "取代並通知 {{count}} 位成員",
@@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import { buildEventType } from "@calcom/lib/test/builder";
import type { CompleteEventType } from "@calcom/prisma/zod";
import type { CompleteEventType, CompleteWorkflowsOnEventTypes } from "@calcom/prisma/zod";
import { prismaMock } from "../../../../tests/config/singleton";
@@ -291,4 +291,85 @@ describe("handleChildrenEventTypes", () => {
expect(result.deletedExistentEventTypes).toEqual([123]);
});
});
describe("Workflows", () => {
it("Links workflows to new and existing assigned members", async () => {
const { schedulingType, id, teamId, locations, timeZone, parentId, userId, ...evType } =
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
workflows: [
{
workflowId: 11,
} as CompleteWorkflowsOnEventTypes,
],
});
prismaMock.$transaction.mockResolvedValue([{ id: 2 }]);
await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
children: [
{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } },
{ hidden: false, owner: { id: 5, name: "", email: "", eventTypeSlugs: [] } },
],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hashedLink: undefined,
locations: [],
parentId: 1,
userId: 5,
users: {
connect: [
{
id: 5,
},
],
},
workflows: {
create: [{ workflowId: 11 }],
},
},
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hashedLink: undefined,
workflows: undefined,
scheduleId: undefined,
},
where: {
userId_parentId: {
userId: 4,
parentId: 1,
},
},
});
expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({
create: {
eventTypeId: 2,
workflowId: 11,
},
update: {},
where: {
workflowId_eventTypeId: {
eventTypeId: 2,
workflowId: 11,
},
},
});
});
});
});
+7 -2
View File
@@ -23,8 +23,12 @@ for module in "$@"; do
git submodule add --force $project "apps/$module"
# Set the default branch to main
git config -f .gitmodules --add "submodule.apps/$module.branch" main
# Adding the subdmoule ignores the `.gitignore` so a reset is needed
git reset
# Update to the latest from main in that submodule
cd apps/$module && git pull origin main && cd ../..
# We forcefully added the subdmoule which was in .gitignore, so unstage it.
git restore --staged apps/$module
else
# If the module is the API, display a link to request access
if [ "$module" = "api" ]; then
@@ -35,3 +39,4 @@ for module in "$@"; do
fi
fi
done
git restore --staged .gitmodules
+2
View File
@@ -17,6 +17,7 @@
"app-store": "yarn app-store-cli cli",
"create-app": "yarn app-store create",
"edit-app": "yarn app-store edit",
"publish-embed": "yarn workspaces foreach --from=\"@calcom/embed*\" npm publish --access=public",
"delete-app": "yarn app-store delete",
"create-app-template": "yarn app-store create-template",
"edit-app-template": "yarn app-store edit-template",
@@ -74,6 +75,7 @@
"web": "yarn workspace @calcom/web"
},
"devDependencies": {
"@changesets/cli": "^2.26.1",
"@deploysentinel/playwright": "^0.3.3",
"@playwright/test": "^1.31.2",
"@snaplet/copycat": "^0.3.0",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@calcom/app-store-cli",
"private": true,
"sideEffects": false,
"version": "0.0.0",
"bin": "dist/cli.js",
@@ -4,9 +4,9 @@ import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import React, { useEffect, useState } from "react";
import { AppMeta } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { getSlugFromAppName, BaseAppFork, Seed, generateAppFiles, getAppDirPath } from "../core";
import { getSlugFromAppName, BaseAppFork, generateAppFiles, getAppDirPath } from "../core";
import { getApp } from "../utils/getApp";
import Templates from "../utils/templates";
import Label from "./Label";
@@ -148,8 +148,6 @@ export const AppForm = ({
oldSlug: givenSlug,
});
await Seed.update({ slug, category: category, oldSlug: givenSlug, isTemplate });
await generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
@@ -242,6 +240,10 @@ export const AppForm = ({
<Text color="green">Publisher Email: </Text>
<Text>{email}</Text>
</Box>
<Text bold>
Next Step: Enable the app from http://localhost:3000/settings/admin/apps as admin user (Email:
admin@example.com, Pass: ADMINadmin2022!)
</Text>
</Box>
</Box>
)}
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
import { ImportantText } from "../components/ImportantText";
import { Message } from "../components/Message";
import { BaseAppFork, Seed, generateAppFiles } from "../core";
import { BaseAppFork, generateAppFiles } from "../core";
import { getApp } from "../utils/getApp";
export default function DeleteForm({ slug, action }: { slug: string; action: "delete" | "delete-template" }) {
@@ -28,7 +28,6 @@ export default function DeleteForm({ slug, action }: { slug: string; action: "de
if (state === "DELETION_CONFIRMATION_SUCCESSFUL") {
(async () => {
await BaseAppFork.delete({ slug, isTemplate });
Seed.revert({ slug });
await generateAppFiles();
// successMsg({ text: `App with slug ${slug} has been deleted`, done: true });
setState("DELETION_COMPLETED");
-58
View File
@@ -1,8 +1,6 @@
import fs from "fs";
import path from "path";
import type seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync";
@@ -27,10 +25,6 @@ export function getAppDirPath(slug: string, isTemplate: boolean) {
return path.join(TEMPLATES_PATH, `${slug}`);
}
function absolutePath(appRelativePath: string) {
return path.join(APP_STORE_PATH, appRelativePath);
}
const updatePackageJson = ({
slug,
appDescription,
@@ -138,58 +132,6 @@ export const BaseAppFork = {
},
};
export const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: async function ({
slug,
category,
oldSlug,
isTemplate,
}: {
slug: string;
category: string;
oldSlug: string;
isTemplate: boolean;
}) {
let configContent = "[]";
try {
if (fs.statSync(this.seedConfigPath)) {
configContent = fs.readFileSync(this.seedConfigPath).toString();
}
} catch (e) {}
let seedConfig: typeof seedAppStoreConfig = JSON.parse(configContent);
seedConfig = seedConfig.filter((app) => app.slug !== oldSlug);
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [category],
slug: slug,
type: `${slug}_${category}`,
isTemplate: isTemplate,
});
}
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
await execSync(`cd ${workspaceDir}/packages/prisma && yarn seed-app-store seed-templates`);
},
revert: async function ({ slug }: { slug: string }) {
let seedConfig: typeof seedAppStoreConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
await execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
},
};
export const generateAppFiles = async () => {
await execSync(`yarn ts-node --transpile-only src/build.ts`);
};
+16 -2
View File
@@ -5,8 +5,22 @@ import { userMetadata } from "@calcom/prisma/zod-utils";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
export async function getAppWithMetadata(app: { dirName: string }) {
const appMetadata: App | null = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata] as App;
/**
* Get App metdata either using dirName or slug
*/
export async function getAppWithMetadata(app: { dirName: string } | { slug: string }) {
let appMetadata: App | null;
if ("dirName" in app) {
appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata] as App;
} else {
const foundEntry = Object.entries(appStoreMetadata).find(([, meta]) => {
return meta.slug === app.slug;
});
if (!foundEntry) return null;
appMetadata = foundEntry[1] as App;
}
if (!appMetadata) return null;
// Let's not leak api keys to the front end
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+8 -1
View File
@@ -12,7 +12,14 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise
if (calendarType?.endsWith("_other_calendar")) {
calendarType = calendarType.split("_other_calendar")[0];
}
const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore]();
const calendarAppImportFn = appStore[calendarType.split("_").join("") as keyof typeof appStore];
if (!calendarAppImportFn) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
const calendarApp = await calendarAppImportFn();
if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
@@ -9,6 +9,7 @@ import { appKeysSchema as giphy_zod_ts } from "./giphy/zod";
import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appKeysSchema as gtm_zod_ts } from "./gtm/zod";
import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
@@ -23,6 +24,7 @@ import { appKeysSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
import { appKeysSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
import { appKeysSchema as vital_zod_ts } from "./vital/zod";
import { appKeysSchema as webex_zod_ts } from "./webex/zod";
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
@@ -37,6 +39,7 @@ export const appKeysSchemas = {
googlecalendar: googlecalendar_zod_ts,
gtm: gtm_zod_ts,
hubspot: hubspot_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
metapixel: metapixel_zod_ts,
office365calendar: office365calendar_zod_ts,
@@ -51,6 +54,7 @@ export const appKeysSchemas = {
"booking-pages-tag": booking_pages_tag_zod_ts,
"event-type-app-card": event_type_app_card_zod_ts,
vital: vital_zod_ts,
webex: webex_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
"zoho-bigin": zoho_bigin_zod_ts,
@@ -55,6 +55,7 @@ import typeform_config_json from "./typeform/config.json";
import vimcal_config_json from "./vimcal/config.json";
import { metadata as vital__metadata_ts } from "./vital/_metadata";
import weather_in_your_calendar_config_json from "./weather_in_your_calendar/config.json";
import webex_config_json from "./webex/config.json";
import whatsapp_config_json from "./whatsapp/config.json";
import whereby_config_json from "./whereby/config.json";
import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
@@ -118,6 +119,7 @@ export const appStoreMetadata = {
vimcal: vimcal_config_json,
vital: vital__metadata_ts,
weather_in_your_calendar: weather_in_your_calendar_config_json,
webex: webex_config_json,
whatsapp: whatsapp_config_json,
whereby: whereby_config_json,
wipemycalother: wipemycalother__metadata_ts,
@@ -9,6 +9,7 @@ import { appDataSchema as giphy_zod_ts } from "./giphy/zod";
import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appDataSchema as gtm_zod_ts } from "./gtm/zod";
import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
@@ -23,6 +24,7 @@ import { appDataSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
import { appDataSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
import { appDataSchema as vital_zod_ts } from "./vital/zod";
import { appDataSchema as webex_zod_ts } from "./webex/zod";
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
@@ -37,6 +39,7 @@ export const appDataSchemas = {
googlecalendar: googlecalendar_zod_ts,
gtm: gtm_zod_ts,
hubspot: hubspot_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
metapixel: metapixel_zod_ts,
office365calendar: office365calendar_zod_ts,
@@ -51,6 +54,7 @@ export const appDataSchemas = {
"booking-pages-tag": booking_pages_tag_zod_ts,
"event-type-app-card": event_type_app_card_zod_ts,
vital: vital_zod_ts,
webex: webex_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
"zoho-bigin": zoho_bigin_zod_ts,
@@ -55,6 +55,7 @@ export const apiHandlers = {
vimcal: import("./vimcal/api"),
vital: import("./vital/api"),
weather_in_your_calendar: import("./weather_in_your_calendar/api"),
webex: import("./webex/api"),
whatsapp: import("./whatsapp/api"),
whereby: import("./whereby/api"),
wipemycalother: import("./wipemycalother/api"),
@@ -93,7 +93,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const body = await translateEvent(event);
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, is_owner: true },
properties: { room_name: dailyEvent.name, exp: dailyEvent.config.exp, is_owner: true },
}).then(meetingTokenSchema.parse);
return Promise.resolve({
+2
View File
@@ -21,6 +21,7 @@ const appStore = {
vital: () => import("./vital"),
zoomvideo: () => import("./zoomvideo"),
wipemycalother: () => import("./wipemycalother"),
webexvideo: () => import("./webex"),
giphy: () => import("./giphy"),
zapier: () => import("./zapier"),
exchange2013calendar: () => import("./exchange2013calendar"),
@@ -28,6 +29,7 @@ const appStore = {
exchangecalendar: () => import("./exchangecalendar"),
facetime: () => import("./facetime"),
sylapsvideo: () => import("./sylapsvideo"),
"zoho-bigin": () => import("./zoho-bigin"),
};
export default appStore;
+1 -1
View File
@@ -3,4 +3,4 @@ items:
- jitsi1.jpg
---
Jitsi is a free open-source video conferencing software for web and mobile. Make a call, launch on your own servers, integrate into your app, and more.
Jitsi is a free open-source video conferencing software for web and mobile. Make a call, launch on your own servers, integrate into your app, and more.
@@ -3,18 +3,27 @@ import { v4 as uuidv4 } from "uuid";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { metadata } from "../_metadata";
const JitsiVideoApiAdapter = (): VideoApiAdapter => {
return {
getAvailability: () => {
return Promise.resolve([]);
},
createMeeting: async (): Promise<VideoCallData> => {
const appKeys = await getAppKeysFromSlug(metadata.slug);
const meetingID = uuidv4();
// Default Value
const hostUrl = appKeys.jitsiHost || "https://meet.jit.si/cal";
return Promise.resolve({
type: "jitsi_video",
type: metadata.type,
id: meetingID,
password: "",
url: "https://meet.jit.si/cal/" + meetingID,
url: hostUrl + "/" + meetingID,
});
},
deleteMeeting: async (): Promise<void> => {
+7
View File
@@ -0,0 +1,7 @@
import { z } from "zod";
export const appKeysSchema = z.object({
jitsiHost: z.string().optional(),
});
export const appDataSchema = z.object({});
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@calcom/app-store",
"private": true,
"sideEffects": false,
"version": "0.0.0",
"main": "./index.ts",
+55 -647
View File
@@ -1,667 +1,75 @@
import type { App_RoutingForms_Form, User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import type { Ensure } from "@calcom/types/utils";
import ResponseEmail from "../emails/templates/response-email";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { createFallbackRoute } from "../lib/createFallbackRoute";
import getConnectedForms from "../lib/getConnectedForms";
import { getSerializableForm } from "../lib/getSerializableForm";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
import isRouter from "../lib/isRouter";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { Response, SerializableForm } from "../types/types";
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
import { ZFormMutationInputSchema } from "./formMutation.schema";
import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZReportInputSchema } from "./report.schema";
import { ZResponseInputSchema } from "./response.schema";
async function onFormSubmission(
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value;
// TODO: Move getHandler and UNSTABLE_HANDLER_CACHE to a common utils file making sure that there is no name collision across routes
/**
* This function will import the module defined in importer just once and then cache the default export of that module.
*
* It gives you the default export of the module.
*
* **Note: It is your job to ensure that the name provided is unique across all routes.**
*/
const getHandler = async <
T extends {
// eslint-disable-next-line @typescript-eslint/ban-types
default: Function;
}
const subscriberOptions = {
userId: form.user.id,
// It isn't an eventType webhook
eventTypeId: -1,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) => {
sendGenericWebhookPayload(
webhook.secret,
"FORM_SUBMITTED",
new Date().toISOString(),
webhook,
fieldResponsesByName
).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
});
});
await Promise.all(promises);
if (form.settings?.emailOwnerOnSubmission) {
logger.debug(
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
);
await sendResponseEmail(form, response, form.user.email);
}
}
const sendResponseEmail = async (
form: Pick<App_RoutingForms_Form, "id" | "name">,
response: Response,
ownerEmail: string
>(
/**
* The name of the handler in cache. It has to be unique across all routes
*/
name: string,
importer: () => Promise<T>
) => {
try {
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response });
await email.sendEmail();
} catch (e) {
logger.error("Error sending response email", e);
const nameInCache = name as keyof typeof UNSTABLE_HANDLER_CACHE;
if (!UNSTABLE_HANDLER_CACHE[nameInCache]) {
const importedModule = await importer();
UNSTABLE_HANDLER_CACHE[nameInCache] = importedModule.default;
return importedModule.default as T["default"];
}
return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"];
};
const appRoutingForms = router({
public: router({
response: publicProcedure
.input(
z.object({
formId: z.string(),
formFillerId: z.string(),
response: z.record(
z.object({
label: z.string(),
value: z.union([z.string(), z.array(z.string())]),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const { prisma } = ctx;
try {
const { response, formId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: true,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const serializableForm = await getSerializableForm(form);
if (!serializableForm.fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new TRPCError({
code: "BAD_REQUEST",
});
}
const serializableFormWithFields = {
...serializableForm,
fields: serializableForm.fields,
};
const missingFields = serializableFormWithFields.fields
.filter((field) => !(field.required ? response[field.id]?.value : true))
.map((f) => f.label);
if (missingFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Missing required fields ${missingFields.join(", ")}`,
});
}
const invalidFields = serializableFormWithFields.fields
.filter((field) => {
const fieldValue = response[field.id]?.value;
// The field isn't required at this point. Validate only if it's set
if (!fieldValue) {
return false;
}
let schema;
if (field.type === "email") {
schema = z.string().email();
} else if (field.type === "phone") {
schema = z.any();
} else {
schema = z.any();
}
return !schema.safeParse(fieldValue).success;
})
.map((f) => ({ label: f.label, type: f.type }));
if (invalidFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`,
});
}
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: input,
});
await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response);
return dbFormResponse;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
});
}
}
throw e;
}
}),
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("response", () => import("./response.handler"));
return handler({ ctx, input });
}),
}),
forms: authedProcedure.query(async ({ ctx }) => {
const { prisma, user } = ctx;
const forms = await prisma.app_RoutingForms_Form.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: "desc",
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
const serializableForms = [];
for (let i = 0; i < forms.length; i++) {
serializableForms.push(await getSerializableForm(forms[i]));
}
return serializableForms;
const handler = await getHandler("forms", () => import("./forms.handler"));
return handler({ ctx });
}),
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
return handler({ ctx, input });
}),
formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("formMutation", () => import("./formMutation.handler"));
return handler({ ctx, input });
}),
deleteForm: authedProcedure.input(ZDeleteFormInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("deleteForm", () => import("./deleteForm.handler"));
return handler({ ctx, input });
}),
formQuery: authedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ ctx, input }) => {
const { prisma, user } = ctx;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
userId: user.id,
id: input.id,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return null;
}
return await getSerializableForm(form);
}),
formMutation: authedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
disabled: z.boolean().optional(),
fields: zodFields,
routes: zodRoutes,
addFallback: z.boolean().optional(),
duplicateFrom: z.string().nullable().optional(),
shouldConnect: z.boolean().optional(),
settings: RoutingFormSettings.optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
let { routes: inputRoutes } = input;
let { fields: inputFields } = input;
inputFields = inputFields || [];
inputRoutes = inputRoutes || [];
type InputFields = typeof inputFields;
type InputRoutes = typeof inputRoutes;
let routes: InputRoutes;
let fields: InputFields;
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: id,
},
select: {
id: true,
user: true,
name: true,
description: true,
userId: true,
disabled: true,
createdAt: true,
updatedAt: true,
routes: true,
fields: true,
settings: true,
},
});
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
if (duplicateFrom) {
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
} else {
[fields, routes] = [inputFields, inputRoutes];
if (dbSerializedForm) {
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
}
}
if (dbSerializedForm) {
// If it's an existing form being mutated, update fields in the connected forms(if any).
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
}
fields = await getUpdatedRouterLinkedFields(fields, routes);
if (addFallback) {
// Add a fallback route if there is none
if (!routes.find(isFallbackRoute)) {
routes.push(createFallbackRoute());
}
}
return await prisma.app_RoutingForms_Form.upsert({
where: {
id: id,
},
create: {
user: {
connect: {
id: user.id,
},
},
fields,
name: name,
description,
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
routes: routes === null ? Prisma.JsonNull : routes,
id: id,
},
update: {
disabled: disabled,
fields,
name: name,
description,
settings: settings === null ? Prisma.JsonNull : settings,
routes: routes === null ? Prisma.JsonNull : routes,
},
});
/**
* If Form has Router Linked fields, enrich them with the latest info from the Router
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
*/
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
const routerLinkedFields: Record<string, boolean> = {};
for (const [, field] of Object.entries(fields)) {
if (!isRouterLinkedField(field)) {
continue;
}
routerLinkedFields[field.routerId] = true;
if (!routes.some((route) => route.id === field.routerId)) {
// If the field is from a router that is not available anymore, mark it as deleted
field.deleted = true;
continue;
}
// Get back deleted field as now the Router is there for it.
if (field.deleted) field.deleted = false;
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: field.routerId,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
// There is a field from some router available, make sure that the field has up-to-date info from the router
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
// Update local field(cache) with router field on every mutation
Object.assign(field, routerField);
}
}
for (const [, route] of Object.entries(routes)) {
if (!isRouter(route)) {
continue;
}
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
if (routerLinkedFields[route.id]) {
continue;
}
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: route.id,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
const fieldsFromRouter = parsedRouterFields
?.filter((f) => !f.deleted)
.map((f) => {
return {
...f,
routerId: route.id,
};
});
if (fieldsFromRouter) {
fields = fields.concat(fieldsFromRouter);
}
}
}
return fields;
}
function findFieldWithId(id: string, fields: InputFields) {
return fields.find((field) => field.id === id);
}
/**
* Update fields in connected forms as per the inputFields
*/
async function updateFieldsInConnectedForms(
serializedForm: SerializableForm<App_RoutingForms_Form>,
inputFields: InputFields
) {
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: connectedForm.id,
},
});
if (!connectedFormDb) {
continue;
}
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
const fieldsThatAreNotInConnectedForm = (
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
).map((f) => ({
...f,
routerId: serializedForm.id,
}));
const updatedConnectedFormFields = connectedFormFields
// Update fields that are already in connected form
?.map((field) => {
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
return {
...field,
...findFieldWithId(field.id, inputFields || []),
};
}
return field;
})
// Add fields that are not there
.concat(fieldsThatAreNotInConnectedForm);
await prisma.app_RoutingForms_Form.update({
where: {
id: connectedForm.id,
},
data: {
fields: updatedConnectedFormFields,
},
});
}
}
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
where: {
userId: user.id,
id: duplicateFrom,
},
select: {
id: true,
fields: true,
routes: true,
},
});
if (!sourceForm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Form to duplicate: ${duplicateFrom} not found`,
});
}
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
if (!fieldsParsed.success || !routesParsed.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Could not parse source form's fields or routes",
});
}
let fields, routes;
if (shouldConnect) {
routes = [
// This connected route would automatically link the fields
zodRouterRoute.parse({
id: sourceForm.id,
isRouter: true,
}),
];
fields =
fieldsParsed.data
// Deleted fields in the form shouldn't be added to the new form
?.filter((f) => !f.deleted)
.map((f) => {
return {
id: f.id,
routerId: sourceForm.id,
label: "",
type: "",
};
}) || [];
} else {
// Duplicate just routes and fields
// We don't want name, description and responses to be copied
routes = routesParsed.data || [];
// FIXME: Deleted fields shouldn't come in duplicate
fields = fieldsParsed.data || [];
}
return { routes, fields };
}
function markMissingFieldsDeleted(
serializedForm: SerializableForm<App_RoutingForms_Form>,
fields: InputFields
) {
// Find all fields that are in DB(including deleted) but not in the mutation
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
const deletedFields =
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
// Add back deleted fields in the end and mark them deleted.
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
fields = fields.concat(
deletedFields.map((f) => {
f.deleted = true;
return f;
})
);
return fields;
}
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
const routesOfRouter = zodRoutes.parse(router.routes);
if (routesOfRouter) {
if (routesOfRouter.find(isRouter)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
});
}
}
}
}),
deleteForm: authedProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
const areFormsUsingIt = (
await getConnectedForms(prisma, {
id: input.id,
userId: user.id,
})
).length;
if (areFormsUsingIt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This form is being used by other forms. Please remove it's usage from there first.",
});
}
return await prisma.app_RoutingForms_Form.deleteMany({
where: {
id: input.id,
userId: user.id,
},
});
}),
report: authedProcedure
.input(
z.object({
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
})
)
.query(async ({ ctx: { prisma }, input }) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = await getSerializableForm(form, true);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
take,
skip,
});
const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
rows.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let stringValue = "";
if (value instanceof Array) {
stringValue = value.join(", ");
} else {
stringValue = value;
}
rowResponses.push(stringValue);
});
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
}),
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
});
export default appRoutingForms;
@@ -41,3 +41,5 @@ export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions
},
});
};
export default deleteFormHandler;
@@ -291,7 +291,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
// We don't want name, description and responses to be copied
routes = routesParsed.data || [];
// FIXME: Deleted fields shouldn't come in duplicate
fields = fieldsParsed.data || [];
fields = fieldsParsed.data ? fieldsParsed.data.filter((f) => !f.deleted) : [];
}
return { routes, fields };
}
@@ -328,3 +328,5 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
}
}
};
export default formMutationHandler;
@@ -34,3 +34,5 @@ export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
return await getSerializableForm(form);
};
export default formQueryHandler;
@@ -35,3 +35,5 @@ export const formsHandler = async ({ ctx }: FormsHandlerOptions) => {
}
return serializableForms;
};
export default formsHandler;
@@ -78,3 +78,5 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
};
export default reportHandler;
@@ -99,3 +99,5 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
throw e;
}
};
export default responseHandler;
+7 -1
View File
@@ -15,7 +15,13 @@ export async function getHandler(req: NextApiRequest) {
const { api_key } = req.body;
if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provided to check" });
const encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || "");
let encrypted;
try {
encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || "");
} catch (reason) {
logger.error("Could not add Sendgrid app", reason);
throw new HttpError({ statusCode: 500, message: "Invalid length - CALENDSO_ENCRYPTION_KEY" });
}
const data = {
type: "sendgrid_other_calendar",
@@ -84,7 +84,7 @@ export default function SendgridSetup() {
onBlur={onBlur}
disabled={testPassed === true}
name="api_key"
placeholder="api_xyz..."
placeholder="SG.xxxxxx..."
onChange={async (e) => {
onChange(e.target.value);
form.setValue("api_key", e.target.value);
@@ -3,7 +3,7 @@
"name": "Weather in your Calendar",
"slug": "weather_in_your_calendar",
"type": "weather_in_your_calendar_other",
"logo": "icon.gif",
"logo": "icon.svg",
"url": "https://weather-in-calendar.com",
"variant": "other",
"categories": ["other"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 812 KiB

@@ -0,0 +1,40 @@
<svg width="112" height="112" viewBox="0 0 112 112" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4670_356386)">
<rect width="112" height="112" rx="8.96" fill="url(#paint0_linear_4670_356386)"/>
<path d="M13.2268 35.84V32.8554L21.1448 25.0934C21.902 24.3287 22.5331 23.6494 23.038 23.0554C23.5428 22.4615 23.9214 21.8861 24.1739 21.3293C24.4263 20.7725 24.5525 20.1785 24.5525 19.5475C24.5525 18.8273 24.3892 18.2111 24.0625 17.6988C23.7358 17.1791 23.2867 16.7782 22.715 16.4961C22.1433 16.214 21.4937 16.0729 20.7661 16.0729C20.0163 16.0729 19.3592 16.2288 18.795 16.5407C18.2308 16.8451 17.7927 17.2794 17.4809 17.8436C17.1765 18.4079 17.0243 19.0798 17.0243 19.8593H13.0932C13.0932 18.4116 13.4236 17.1532 14.0843 16.0841C14.7451 15.015 15.6545 14.1872 16.8127 13.6007C17.9783 13.0141 19.3147 12.7209 20.8218 12.7209C22.3512 12.7209 23.695 13.0067 24.8532 13.5784C26.0114 14.1501 26.9097 14.9333 27.5482 15.9282C28.1941 16.923 28.517 18.0589 28.517 19.3359C28.517 20.1897 28.3537 21.0286 28.027 21.8527C27.7004 22.6768 27.125 23.59 26.3009 24.5923C25.4842 25.5945 24.3372 26.8084 22.8598 28.2338L18.9286 32.2318V32.3877H28.8623V35.84H13.2268Z" fill="#FEA740"/>
<path d="M40.7265 13.0327V35.84H36.5949V17.0529H36.4613L31.127 20.4607V16.6743L36.7954 13.0327H40.7265Z" fill="#FEA740"/>
<path d="M50.4192 23.5788C49.4317 23.5788 48.5334 23.3413 47.7242 22.8661C46.9149 22.3835 46.269 21.7376 45.7864 20.9284C45.3039 20.1117 45.0626 19.2097 45.0626 18.2222C45.0626 17.2422 45.3039 16.3476 45.7864 15.5384C46.269 14.7217 46.9149 14.0758 47.7242 13.6007C48.5334 13.1181 49.4317 12.8768 50.4192 12.8768C51.4066 12.8768 52.3049 13.1181 53.1142 13.6007C53.9234 14.0758 54.5693 14.7217 55.0519 15.5384C55.5345 16.3476 55.7757 17.2422 55.7757 18.2222C55.7757 19.2097 55.5345 20.1117 55.0519 20.9284C54.5693 21.7376 53.9234 22.3835 53.1142 22.8661C52.3049 23.3413 51.4066 23.5788 50.4192 23.5788ZM50.4192 20.7279C50.8795 20.7279 51.2989 20.6166 51.6776 20.3938C52.0562 20.1711 52.3569 19.8704 52.5796 19.4918C52.8098 19.1132 52.9248 18.69 52.9248 18.2222C52.9248 17.7619 52.8098 17.3425 52.5796 16.9638C52.3569 16.5852 52.0562 16.2845 51.6776 16.0618C51.2989 15.8391 50.8795 15.7277 50.4192 15.7277C49.9588 15.7277 49.5394 15.8391 49.1607 16.0618C48.7821 16.2845 48.4777 16.5852 48.2476 16.9638C48.0248 17.3425 47.9135 17.7619 47.9135 18.2222C47.9135 18.6826 48.0248 19.1057 48.2476 19.4918C48.4703 19.8704 48.771 20.1711 49.1496 20.3938C49.5357 20.6166 49.9588 20.7279 50.4192 20.7279Z" fill="#FEA740"/>
<ellipse opacity="0.3" cx="104.427" cy="71.5335" rx="62.9874" ry="56.9735" fill="#FECF40"/>
<ellipse cx="111.52" cy="75.04" rx="54.4" ry="44.8" fill="#FECF40"/>
<g filter="url(#filter0_d_4670_356386)">
<path d="M-0.897857 82.8196C5.08881 66.081 21.2424 54.0886 40.2303 54.0886C59.2181 54.0886 75.3717 66.081 81.3584 82.8196H85.3791C102.097 82.8196 115.649 96.372 115.649 113.09C115.649 129.808 102.097 143.36 85.3791 143.36H-3.89228C-20.61 143.36 -34.1625 129.808 -34.1625 113.09C-34.1625 96.372 -20.61 82.8196 -3.89226 82.8196H-0.897857Z" fill="url(#paint1_linear_4670_356386)"/>
</g>
<path d="M12.3327 86.6472H15.7309L19.32 95.4036H19.4727L23.0618 86.6472H26.46V99.68H23.7873V91.1972H23.6791L20.3064 99.6163H18.4864L15.1136 91.1654H15.0055V99.68H12.3327V86.6472Z" fill="#8EA3B8"/>
<path d="M40.7066 93.1636C40.7066 94.5848 40.4372 95.7939 39.8984 96.7909C39.3639 97.7878 38.6342 98.5494 37.7093 99.0754C36.7887 99.5972 35.7536 99.8581 34.6039 99.8581C33.4457 99.8581 32.4063 99.5951 31.4857 99.0691C30.5651 98.543 29.8375 97.7815 29.303 96.7845C28.7684 95.7875 28.5011 94.5806 28.5011 93.1636C28.5011 91.7424 28.7684 90.5333 29.303 89.5363C29.8375 88.5394 30.5651 87.78 31.4857 87.2581C32.4063 86.7321 33.4457 86.4691 34.6039 86.4691C35.7536 86.4691 36.7887 86.7321 37.7093 87.2581C38.6342 87.78 39.3639 88.5394 39.8984 89.5363C40.4372 90.5333 40.7066 91.7424 40.7066 93.1636ZM37.913 93.1636C37.913 92.243 37.7751 91.4666 37.4993 90.8345C37.2278 90.2024 36.8439 89.723 36.3475 89.3963C35.8511 89.0697 35.2699 88.9063 34.6039 88.9063C33.9378 88.9063 33.3566 89.0697 32.8602 89.3963C32.3639 89.723 31.9778 90.2024 31.702 90.8345C31.4305 91.4666 31.2948 92.243 31.2948 93.1636C31.2948 94.0842 31.4305 94.8606 31.702 95.4927C31.9778 96.1248 32.3639 96.6042 32.8602 96.9309C33.3566 97.2575 33.9378 97.4209 34.6039 97.4209C35.2699 97.4209 35.8511 97.2575 36.3475 96.9309C36.8439 96.6042 37.2278 96.1248 37.4993 95.4927C37.7751 94.8606 37.913 94.0842 37.913 93.1636Z" fill="#8EA3B8"/>
<path d="M53.6486 86.6472V99.68H51.2686L45.5986 91.4772H45.5032V99.68H42.7477V86.6472H45.1659L50.7914 94.8436H50.9059V86.6472H53.6486Z" fill="#8EA3B8"/>
</g>
<rect x="0.56" y="0.56" width="110.88" height="110.88" rx="8.4" stroke="#FFC582" stroke-opacity="0.15" stroke-width="1.12"/>
<defs>
<filter id="filter0_d_4670_356386" x="-38.6425" y="54.0886" width="158.772" height="98.2314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.48"/>
<feGaussianBlur stdDeviation="2.24"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4670_356386"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4670_356386" result="shape"/>
</filter>
<linearGradient id="paint0_linear_4670_356386" x1="56" y1="0" x2="56" y2="112" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF9E8"/>
<stop offset="0.181388" stop-color="#FFF2C9"/>
</linearGradient>
<linearGradient id="paint1_linear_4670_356386" x1="40.7434" y1="54.0886" x2="40.7434" y2="143.36" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.96875" stop-color="#D7ECFF"/>
</linearGradient>
<clipPath id="clip0_4670_356386">
<rect width="112" height="112" rx="8.96" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+9
View File
@@ -0,0 +1,9 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
- 4.jpeg
---
{DESCRIPTION}
+14
View File
@@ -0,0 +1,14 @@
### Obtaining Webex Client ID and Secret
1. Create a [Webex](https://www.webex.com/) acount, if you don't already have one.
2. Go to [Webex for Developers](https://developer.webex.com/) and sign into to your Webex account. (Note: If you're creating a new account, create it on [Webex](https://www.webex.com/), not on [Webex for Developers](https://developer.webex.com/))
3. On the upper right, click the profile icon and go to ["My Webex Apps"](https://developer.webex.com/my-apps)
4. Click on "Create a New App" and select ["Integration"](https://developer.webex.com/my-apps/new/integration)
5. Choose "No" for "Will this use a mobile SDK?"
6. Give your app a name.
7. Upload an icon or choose one of the default icons.
8. Give your app a short description.
9. Set the Redirect URI as `<Cal.com URL>/api/integrations/webex/callback` replacing Cal.com URL with the URI at which your application runs.
10. Select the following scopes: "meeting:schedules_read", "meeting:schedules_write".
11. Click "Add Integration".
12. Copy the Client ID and Client Secret and add these while enabling the app through Settings -> Admin -> Apps interface
+39
View File
@@ -0,0 +1,39 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import config from "../config.json";
import { getWebexAppKeys } from "../lib/getWebexAppKeys";
async function handler(req: NextApiRequest) {
// Get user
await prisma.user.findFirstOrThrow({
where: {
id: req.session?.user?.id,
},
select: {
id: true,
},
});
const { client_id } = await getWebexAppKeys();
/** @link https://developer.webex.com/docs/integrations#requesting-permission */
const params = {
response_type: "code",
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
state: "",
};
const query = stringify(params).replaceAll("+", "%20");
const url = `https://webexapis.com/v1/authorize?${query}`;
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+99
View File
@@ -0,0 +1,99 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import config from "../config.json";
import { getWebexAppKeys } from "../lib/getWebexAppKeys";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const { client_id, client_secret } = await getWebexAppKeys();
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64");
const result = await fetch(
"https://webexapis.com/v1/access_token?grant_type=authorization_code&client_id" +
client_id +
"&client_secret=" +
client_secret +
"&code=" +
code +
"&redirect_uri=" +
redirectUri,
{
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
if (result.status !== 200) {
let errorMessage = "Something is wrong with Webex API";
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {}
res.status(400).json({ message: errorMessage });
return;
}
const responseBody = await result.json();
if (responseBody.error) {
res.status(400).json({ message: responseBody.error });
return;
}
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
const userId = req.session?.user.id;
if (!userId) {
return res.status(404).json({ message: "No user found" });
}
/**
* With this we take care of no duplicate webex key for a single user
* when creating a video room we only do findFirst so the if they have more than 1
* others get ignored
* */
const existingCredentialWebexVideo = await prisma.credential.findMany({
select: {
id: true,
},
where: {
type: config.type,
userId: req.session?.user.id,
appId: config.slug,
},
});
// Making sure we only delete webex_video
const credentialIdsToDelete = existingCredentialWebexVideo.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await prisma.credential.deleteMany({ where: { id: { in: credentialIdsToDelete }, userId } });
}
await prisma.user.update({
where: {
id: req.session?.user.id,
},
data: {
credentials: {
create: {
type: config.type,
key: responseBody,
appId: config.slug,
},
},
},
});
res.redirect(getInstalledAppPath({ variant: config.variant, slug: config.slug }));
}
+2
View File
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
+25
View File
@@ -0,0 +1,25 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Webex",
"title": "Webex",
"slug": "webex",
"type": "webex_video",
"imageSrc": "/icon.ico",
"logo": "/icon.ico",
"url": "https://cal.com/apps/webex",
"variant": "conferencing",
"categories": ["video"],
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "Create meetings with Cisco Webex",
"appData": {
"location": {
"linkType": "dynamic",
"type": "integrations:webex_video",
"label": "Webex"
}
},
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic"
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,304 @@
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import type { Credential } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import { getWebexAppKeys } from "./getWebexAppKeys";
/** @link https://developer.webex.com/docs/meetings **/
const webexEventResultSchema = z.object({
id: z.string(),
webLink: z.string(),
siteUrl: z.string(),
password: z.string().optional().default(""),
});
export type WebexEventResult = z.infer<typeof webexEventResultSchema>;
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
export const webexMeetingSchema = z.object({
start: z.date(),
end: z.date(),
});
/** @link https://developer.webex.com/docs/api/v1/meetings/list-meetings */
export const webexMeetingsSchema = z.object({
items: z.array(webexMeetingSchema),
});
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token */
const webexTokenSchema = z.object({
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
token_type: z.literal("Bearer"),
access_token: z.string(),
expires_in: z.number().optional(),
refresh_token: z.string(),
refresh_token_expires_in: z.number(),
expiry_date: z.number(),
});
type WebexToken = z.infer<typeof webexTokenSchema>;
const isTokenValid = (token: WebexToken) => token.expiry_date < Date.now();
/** @link https://developer.webex.com/docs/integrations#using-the-refresh-token */
const webexRefreshedTokenSchema = z.object({
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
token_type: z.literal("Bearer"),
access_token: z.string(),
expires_in: z.number().optional(),
refresh_token: z.string(),
refresh_token_expires_in: z.number(),
});
const webexAuth = (credential: CredentialPayload) => {
const refreshAccessToken = async (refreshToken: string) => {
const { client_id, client_secret } = await getWebexAppKeys();
const response = await fetch("https://webexapis.com/v1/access_token", {
method: "POST",
headers: {
"Content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: client_id,
client_secret: client_secret,
refresh_token: refreshToken,
}),
});
const responseBody = await handleWebexResponse(response, credential.id);
if (responseBody.error) {
if (responseBody.error === "invalid_grant") {
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
}
}
// We check the if the new credentials matches the expected response structure
const parsedToken = webexRefreshedTokenSchema.safeParse(responseBody);
if (!parsedToken.success) {
return Promise.reject(new Error("Invalid refreshed tokens were returned"));
}
const newTokens = parsedToken.data;
const oldCredential = await prisma.credential.findUniqueOrThrow({ where: { id: credential.id } });
const parsedKey = webexTokenSchema.safeParse(oldCredential.key);
if (!parsedKey.success) {
return Promise.reject(new Error("Invalid credentials were saved in the DB"));
}
const key = parsedKey.data;
key.access_token = newTokens.access_token;
key.refresh_token = newTokens.refresh_token;
// set expiry date as offset from current time.
if (newTokens.expires_in) {
key.expiry_date = Math.round(Date.now() + newTokens.expires_in * 1000);
}
// Store new tokens in database.
await prisma.credential.update({ where: { id: credential.id }, data: { key } });
return newTokens.access_token;
};
return {
getToken: async () => {
let credentialKey: WebexToken | null = null;
try {
credentialKey = webexTokenSchema.parse(credential.key);
} catch (error) {
return Promise.reject("Webex credential keys parsing error");
}
return !isTokenValid(credentialKey)
? Promise.resolve(credentialKey.access_token)
: refreshAccessToken(credentialKey.refresh_token);
},
};
};
const WebexVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
//TODO implement translateEvent for recurring events
const translateEvent = (event: CalendarEvent) => {
//To convert the Cal's CalendarEvent type to a webex meeting type
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
//Required params - title, start, end
return {
title: event.title,
start: dayjs(event.startTime).utc().format(),
end: dayjs(event.endTime).utc().format(),
recurrence: event.recurrence, //Follows RFC 2445 https://www.ietf.org/rfc/rfc2445.txt, TODO check if needs conversion
// timezone: event.organizer.timeZone, // Comment this out for now
agenda: event.description,
enableJoinBeforeHost: true, //this is true in zoom's api, do we need it here?
invitees: event.attendees.map((attendee) => ({
email: attendee.email,
})),
sendEmail: true,
};
};
const fetchWebexApi = async (endpoint: string, options?: RequestInit) => {
const auth = webexAuth(credential);
const accessToken = await auth.getToken();
console.log("result of accessToken in fetchWebexApi", accessToken);
console.log("createMeeting options in fetchWebexApi", options);
const response = await fetch(`https://webexapis.com/v1/${endpoint}`, {
method: "GET",
...options,
headers: {
Authorization: "Bearer " + accessToken,
...options?.headers,
},
});
const responseBody = await handleWebexResponse(response, credential.id);
return responseBody;
};
return {
getAvailability: async () => {
try {
const responseBody = await fetchWebexApi("meetings");
const data = webexMeetingsSchema.passthrough().parse(responseBody);
return data.items.map((meeting) => ({
start: meeting.start,
end: meeting.end,
}));
} catch (err) {
console.error(err);
return [];
}
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
try {
console.log("Creating meeting", event);
console.log("meting body", translateEvent(event));
console.log("request body in createMeeting", JSON.stringify(translateEvent(event)));
const response = await fetchWebexApi("meetings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
});
console.log("Webex create meeting response", response);
if (response.error) {
if (response.error === "invalid_grant") {
await invalidateCredential(credential.id);
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
}
}
const result = webexEventResultSchema.parse(response);
if (result.id && result.webLink) {
return {
type: "webex_video",
id: result.id.toString(),
password: result.password || "",
url: result.webLink,
};
}
throw new Error("Failed to create meeting. Response is " + JSON.stringify(result));
} catch (err) {
console.error(err);
throw new Error("Unexpected error");
}
},
deleteMeeting: async (uid: string): Promise<void> => {
/** @link https://developer.webex.com/docs/api/v1/meetings/delete-a-meeting */
try {
const response = await fetchWebexApi(`meetings/${uid}`, {
method: "DELETE",
});
console.log("Webex delete meeting response", response);
if (response.error) {
if (response.error === "invalid_grant") {
await invalidateCredential(credential.id);
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
}
}
return Promise.resolve();
} catch (err) {
return Promise.reject(new Error("Failed to delete meeting"));
}
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => {
/** @link https://developer.webex.com/docs/api/v1/meetings/update-a-meeting */
try {
const response = await fetchWebexApi(`meetings/${bookingRef.uid}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
});
if (response.error) {
if (response.error === "invalid_grant") {
await invalidateCredential(credential.id);
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
}
}
const result = webexEventResultSchema.parse(response);
if (result.id && result.webLink) {
return {
type: "webex_video",
id: bookingRef.meetingId as string,
password: result.password || "",
url: result.webLink,
};
}
throw new Error("Failed to create meeting. Response is " + JSON.stringify(result));
} catch (err) {
console.error(err);
throw new Error("Unexpected error");
}
},
};
};
const handleWebexResponse = async (response: Response, credentialId: Credential["id"]) => {
let _response = response.clone();
const responseClone = response.clone();
if (_response.headers.get("content-encoding") === "gzip") {
const responseString = await response.text();
_response = JSON.parse(responseString);
}
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await _response.json();
if ((response && response.status === 124) || responseBody.error === "invalid_grant") {
await invalidateCredential(credentialId);
}
throw Error(response.statusText);
}
// handle 204 response code with empty response (causes crash otherwise as "" is invalid JSON)
if (response.status === 204) {
return;
}
return responseClone.json();
};
const invalidateCredential = async (credentialId: Credential["id"]) => {
const credential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (credential) {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
invalid: true,
},
});
}
};
export default WebexVideoApiAdapter;
@@ -0,0 +1,13 @@
import { z } from "zod";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const webexAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
export const getWebexAppKeys = async () => {
const appKeys = await getAppKeysFromSlug("webex");
return webexAppKeysSchema.parse(appKeys);
};
+2
View File
@@ -0,0 +1,2 @@
export { getWebexAppKeys } from "./getWebexAppKeys";
export { default as VideoApiAdapter } from "./VideoApiAdapter";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/webex",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Create meetings with Cisco Webex"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

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