From 02b935bcde3d32e131ab909fb5c02ea3eb9afb00 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Wed, 4 May 2022 01:16:59 +0200 Subject: [PATCH 01/55] Feat/zapier app (#2623) * create basic app structure * add zapierSubscription model to prisma.schema * change column name triggerEvent to lower case * add zapier functionality + enpoints + adjust prisma.schema * add subscriptionType + refactor code * add app store information * create setup page to generate api key * clean code * add copy functionality in setup page * clean code * add apiKeyType and delte key when uninstalled or new key generated * clean code * use Promise.all * only approve zapier api key * clean code * fix findValidApiKey for api keys that don't expire * fix migrations * clean code * small fixes * add i18n * add README.md file * add setup guide to README.md * fix yarn.lock * Renames zapierother to zapier * Typo * Updates package name * Rename fixes * Adds zapier to the App Store seeder * Adds missing zapier to apiHandlers * Adds credential relationship to App * Rename fixes * Allows tailwind to pick up custom app-store components * Consolidates zapier_setup_instructions * Webhook fixes * Uses app relationship instead of custom type * Refactors sendPayload to accept webhook object Instead of individual parameters * refactoring * Removes unused zapier check * Update cancel.ts * Refactoring * Removes example comments * Update InstallAppButton.tsx * Type fixes * E2E fixes * Deletes all user zapier webhooks on integration removal Co-authored-by: CarinaWolli Co-authored-by: zomars --- .../components/webhook/WebhookDialogForm.tsx | 2 +- apps/web/lib/webhooks/sendPayload.tsx | 30 +++-- apps/web/lib/webhooks/subscriptions.tsx | 5 +- apps/web/pages/api/book/event.ts | 16 +-- apps/web/pages/api/cancel.ts | 14 +- apps/web/pages/api/integrations.ts | 44 +++++-- apps/web/pages/apps/setup/[appName].tsx | 38 ++++++ apps/web/public/static/locales/en/common.json | 7 + apps/web/public/static/locales/es/common.json | 3 +- apps/web/server/routers/viewer/apiKeys.tsx | 23 ++++ apps/web/server/routers/viewer/webhook.tsx | 27 ++-- apps/web/tailwind.config.js | 6 +- .../app-store/_utils/getAppKeysFromSlug.ts | 2 +- packages/app-store/apiHandlers.tsx | 2 + packages/app-store/components.tsx | 1 + packages/app-store/index.ts | 2 + packages/app-store/metadata.ts | 2 + packages/app-store/zapier/README.md | 71 ++++++++++ packages/app-store/zapier/README.mdx | 5 + packages/app-store/zapier/_metadata.ts | 25 ++++ packages/app-store/zapier/api/add.ts | 39 ++++++ packages/app-store/zapier/api/index.ts | 4 + .../api/subscriptions/addSubscription.ts | 39 ++++++ .../api/subscriptions/deleteSubscription.ts | 29 +++++ .../zapier/api/subscriptions/listBookings.ts | 49 +++++++ .../zapier/components/InstallAppButton.tsx | 18 +++ packages/app-store/zapier/components/icon.tsx | 15 +++ packages/app-store/zapier/components/index.ts | 3 + .../zapier/components/zapierSetup.tsx | 121 ++++++++++++++++++ packages/app-store/zapier/index.ts | 3 + packages/app-store/zapier/package.json | 14 ++ packages/app-store/zapier/static/icon.svg | 3 + packages/ee/lib/api/findValidApiKey.ts | 32 +++++ .../migration.sql | 11 ++ packages/prisma/schema.prisma | 6 + packages/prisma/seed-app-store.ts | 1 + packages/prisma/zod/webhook.ts | 5 +- 37 files changed, 660 insertions(+), 57 deletions(-) create mode 100644 apps/web/pages/apps/setup/[appName].tsx create mode 100644 packages/app-store/zapier/README.md create mode 100644 packages/app-store/zapier/README.mdx create mode 100644 packages/app-store/zapier/_metadata.ts create mode 100644 packages/app-store/zapier/api/add.ts create mode 100644 packages/app-store/zapier/api/index.ts create mode 100644 packages/app-store/zapier/api/subscriptions/addSubscription.ts create mode 100644 packages/app-store/zapier/api/subscriptions/deleteSubscription.ts create mode 100644 packages/app-store/zapier/api/subscriptions/listBookings.ts create mode 100644 packages/app-store/zapier/components/InstallAppButton.tsx create mode 100644 packages/app-store/zapier/components/icon.tsx create mode 100644 packages/app-store/zapier/components/index.ts create mode 100644 packages/app-store/zapier/components/zapierSetup.tsx create mode 100644 packages/app-store/zapier/index.ts create mode 100644 packages/app-store/zapier/package.json create mode 100644 packages/app-store/zapier/static/icon.svg create mode 100644 packages/ee/lib/api/findValidApiKey.ts create mode 100644 packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx index 6e44bdac0d..a676f4b1bc 100644 --- a/apps/web/components/webhook/WebhookDialogForm.tsx +++ b/apps/web/components/webhook/WebhookDialogForm.tsx @@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: { subscriberUrl: "", active: true, payloadTemplate: null, - } as Omit, + } as Omit, } = props; const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate); diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx index f201e76d7c..e6a81cb016 100644 --- a/apps/web/lib/webhooks/sendPayload.tsx +++ b/apps/web/lib/webhooks/sendPayload.tsx @@ -1,3 +1,4 @@ +import { Webhook } from "@prisma/client"; import { compile } from "handlebars"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -24,13 +25,13 @@ function jsonParse(jsonString: string) { const sendPayload = async ( triggerEvent: string, createdAt: string, - subscriberUrl: string, + webhook: Pick, data: CalendarEvent & { metadata?: { [key: string]: string }; rescheduleUid?: string; - }, - template?: string | null + } ) => { + const { subscriberUrl, appId, payloadTemplate: template } = webhook; if (!subscriberUrl || !data) { throw new Error("Missing required elements to send webhook payload."); } @@ -38,13 +39,22 @@ const sendPayload = async ( const contentType = !template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded"; - const body = template - ? applyTemplate(template, data, contentType) - : JSON.stringify({ - triggerEvent: triggerEvent, - createdAt: createdAt, - payload: data, - }); + data.description = data.description || data.additionalNotes; + + let body; + + /* Zapier id is hardcoded in the DB, we send the raw data for this case */ + if (appId === "zapier") { + body = JSON.stringify(data); + } else if (template) { + body = applyTemplate(template, data, contentType); + } else { + body = JSON.stringify({ + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: data, + }); + } const response = await fetch(subscriberUrl, { method: "POST", diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx index dc8ac85cb4..25db083a39 100644 --- a/apps/web/lib/webhooks/subscriptions.tsx +++ b/apps/web/lib/webhooks/subscriptions.tsx @@ -8,7 +8,7 @@ export type GetSubscriberOptions = { triggerEvent: WebhookTriggerEvents; }; -const getSubscribers = async (options: GetSubscriberOptions) => { +const getWebhooks = async (options: GetSubscriberOptions) => { const { userId, eventTypeId } = options; const allWebhooks = await prisma.webhook.findMany({ where: { @@ -32,10 +32,11 @@ const getSubscribers = async (options: GetSubscriberOptions) => { select: { subscriberUrl: true, payloadTemplate: true, + appId: true, }, }); return allWebhooks; }; -export default getSubscribers; +export default getWebhooks; diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 385a502126..baf2c17f43 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -753,17 +753,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) metadata: reqBody.metadata, }); const promises = subscribers.map((sub) => - sendPayload( - eventTrigger, - new Date().toISOString(), - sub.subscriberUrl, - { - ...evt, - rescheduleUid, - metadata: reqBody.metadata, - }, - sub.payloadTemplate - ).catch((e) => { + sendPayload(eventTrigger, new Date().toISOString(), sub, { + ...evt, + rescheduleUid, + metadata: reqBody.metadata, + }).catch((e) => { console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); }) ); diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index d5dc45e6e5..9473efbac5 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -14,7 +14,7 @@ import { getSession } from "@lib/auth"; import { sendCancelledEmails } from "@lib/emails/email-manager"; import prisma from "@lib/prisma"; import sendPayload from "@lib/webhooks/sendPayload"; -import getSubscribers from "@lib/webhooks/subscriptions"; +import getWebhooks from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; @@ -136,13 +136,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) eventTypeId: (bookingToDelete.eventTypeId as number) || 0, triggerEvent: eventTrigger, }; - const subscribers = await getSubscribers(subscriberOptions); - const promises = subscribers.map((sub) => - sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( - (e) => { - console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); - } - ) + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); + }) ); await Promise.all(promises); diff --git a/apps/web/pages/api/integrations.ts b/apps/web/pages/api/integrations.ts index 76b0b2969a..06f40cb205 100644 --- a/apps/web/pages/api/integrations.ts +++ b/apps/web/pages/api/integrations.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; @@ -10,8 +11,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Check that user is authenticated const session = await getSession({ req }); + const userId = session?.user?.id; - if (!session) { + if (!userId) { res.status(401).json({ message: "You must be logged in to do this" }); return; } @@ -19,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === "GET") { const credentials = await prisma.credential.findMany({ where: { - userId: session.user?.id, + userId, }, select: { type: true, @@ -31,18 +33,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method == "DELETE") { const id = req.body.id; + const data: Prisma.UserUpdateInput = { + credentials: { + delete: { + id, + }, + }, + }; + const integration = await prisma.credential.findUnique({ + where: { + id, + }, + }); + /* If the user deletes a zapier integration, we delete all his api keys as well. */ + if (integration?.appId === "zapier") { + data.apiKeys = { + deleteMany: { + userId, + appId: "zapier", + }, + }; + /* We also delete all user's zapier wehbooks */ + data.webhooks = { + deleteMany: { + userId, + appId: "zapier", + }, + }; + } await prisma.user.update({ where: { - id: session?.user?.id, - }, - data: { - credentials: { - delete: { - id, - }, - }, + id: userId, }, + data, }); res.status(200).json({ message: "Integration deleted successfully" }); diff --git a/apps/web/pages/apps/setup/[appName].tsx b/apps/web/pages/apps/setup/[appName].tsx new file mode 100644 index 0000000000..b8bdc7c6f6 --- /dev/null +++ b/apps/web/pages/apps/setup/[appName].tsx @@ -0,0 +1,38 @@ +import { useSession } from "next-auth/react"; +import { useRouter } from "next/router"; + +import _zapierMetadata from "@calcom/app-store/zapier/_metadata"; +import { ZapierSetup } from "@calcom/app-store/zapier/components"; + +import { trpc } from "@lib/trpc"; + +import Loader from "@components/Loader"; + +export default function SetupInformation() { + const router = useRouter(); + const appName = router.query.appName; + const { status } = useSession(); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "unauthenticated") { + router.replace({ + pathname: "/auth/login", + query: { + callbackUrl: `/apps/setup/${appName}`, + }, + }); + } + + if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") { + return ; + } + + return null; +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 33476b7698..470d7425e9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -774,5 +774,12 @@ "impersonate_user_tip":"All uses of this feature is audited.", "impersonating_user_warning":"Impersonating username \"{{user}}\".", "impersonating_stop_instructions": "<0>Click Here to stop.", + "setting_up_zapier": "Setting up your Zapier integration", + "generate_api_key": "Generate Api Key", + "your_unique_api_key": "Your unique API key", + "copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.", + "zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.<1>Select Cal.com as your Trigger app. Also choose a Trigger event.<2>Choose your account and then enter your Unique API Key.<3>Test your Trigger.<4>You're set!", + "install_zapier_app": "Please first install the Zapier App in the app store.", + "go_to_app_store": "Go to App Store", "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions" } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 25a81a58db..7d59283c94 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -660,5 +660,6 @@ "availability_updated_successfully": "Disponibilidad actualizada correctamente", "requires_ownership_of_a_token": "Requiere la propiedad de un token perteneciente a la siguiente dirección:", "example_name": "Juan Pérez", - "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} })." + "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} }).", + "zapier_setup_instructions": "<0>Inicia sesión en tu cuenta de Zapier y crea un nuevo Zap.<1>Selecciona Cal.com cómo tu aplicación disparadora. Tambien elige tu evento disparador.<2>Elige tu cuenta e ingresa tu Clave API única.<3>Prueba tu disparador.<4>¡Listo!" } diff --git a/apps/web/server/routers/viewer/apiKeys.tsx b/apps/web/server/routers/viewer/apiKeys.tsx index 0b95986ff6..70c5f08d89 100644 --- a/apps/web/server/routers/viewer/apiKeys.tsx +++ b/apps/web/server/routers/viewer/apiKeys.tsx @@ -11,16 +11,39 @@ export const apiKeysRouter = createProtectedRouter() return await ctx.prisma.apiKey.findMany({ where: { userId: ctx.user.id, + NOT: { + appId: "zapier", + }, }, orderBy: { createdAt: "desc" }, }); }, }) + .query("findKeyOfType", { + input: z.object({ + appId: z.string().optional().nullable(), + }), + async resolve({ ctx, input }) { + return await ctx.prisma.apiKey.findFirst({ + where: { + AND: [ + { + userId: ctx.user.id, + }, + { + appId: input.appId, + }, + ], + }, + }); + }, + }) .mutation("create", { input: z.object({ note: z.string().optional().nullish(), expiresAt: z.date().optional().nullable(), neverExpires: z.boolean().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { const [hashedApiKey, apiKey] = generateUniqueAPIKey(); diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx index 8386d8540a..0c161f3875 100644 --- a/apps/web/server/routers/viewer/webhook.tsx +++ b/apps/web/server/routers/viewer/webhook.tsx @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import { v4 } from "uuid"; import { z } from "zod"; @@ -17,17 +18,18 @@ export const webhookRouter = createProtectedRouter() }) .optional(), async resolve({ ctx, input }) { - if (input?.eventTypeId) { - return await ctx.prisma.webhook.findMany({ - where: { - eventTypeId: input.eventTypeId, - }, - }); + let where: Prisma.WebhookWhereInput = { + AND: [{ appId: null /* Don't mixup zapier webhooks with normal ones */ }], + }; + if (Array.isArray(where.AND)) { + if (input?.eventTypeId) { + where.AND?.push({ eventTypeId: input.eventTypeId }); + } else { + where.AND?.push({ userId: ctx.user.id }); + } } return await ctx.prisma.webhook.findMany({ - where: { - userId: ctx.user.id, - }, + where, }); }, }) @@ -38,6 +40,7 @@ export const webhookRouter = createProtectedRouter() active: z.boolean(), payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { if (input.eventTypeId) { @@ -65,6 +68,7 @@ export const webhookRouter = createProtectedRouter() active: z.boolean().optional(), payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { const { id, ...data } = input; @@ -139,7 +143,7 @@ export const webhookRouter = createProtectedRouter() payloadTemplate: z.string().optional().nullable(), }), async resolve({ input }) { - const { url, type, payloadTemplate } = input; + const { url, type, payloadTemplate = null } = input; const translation = await getTranslation("en", "common"); const language = { locale: "en", @@ -170,7 +174,8 @@ export const webhookRouter = createProtectedRouter() }; try { - return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate); + const webhook = { subscriberUrl: url, payloadTemplate, appId: null }; + return await sendPayload(type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index ad441af5bd..3a93df3593 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,5 +1,9 @@ const base = require("@calcom/config/tailwind-preset"); module.exports = { ...base, - content: [...base.content, "../../packages/ui/**/*.{js,ts,jsx,tsx}"], + content: [ + ...base.content, + "../../packages/ui/**/*.{js,ts,jsx,tsx}", + "../../packages/app-store/**/components/*.{js,ts,jsx,tsx}", + ], }; diff --git a/packages/app-store/_utils/getAppKeysFromSlug.ts b/packages/app-store/_utils/getAppKeysFromSlug.ts index f76fa9d0cb..a106ec9622 100644 --- a/packages/app-store/_utils/getAppKeysFromSlug.ts +++ b/packages/app-store/_utils/getAppKeysFromSlug.ts @@ -4,7 +4,7 @@ import prisma from "@calcom/prisma"; async function getAppKeysFromSlug(slug: string) { const app = await prisma.app.findUnique({ where: { slug } }); - return app?.keys as Prisma.JsonObject; + return (app?.keys || {}) as Prisma.JsonObject; } export default getAppKeysFromSlug; diff --git a/packages/app-store/apiHandlers.tsx b/packages/app-store/apiHandlers.tsx index c85351362e..c6b988041f 100644 --- a/packages/app-store/apiHandlers.tsx +++ b/packages/app-store/apiHandlers.tsx @@ -15,6 +15,8 @@ export const apiHandlers = { huddle01video: import("./huddle01video/api"), metamask: import("./metamask/api"), giphy: import("./giphy/api"), + // @todo Until we use DB slugs everywhere + zapierother: import("./zapier/api"), }; export default apiHandlers; diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index 39ebc96211..c36b7a08be 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -21,6 +21,7 @@ export const InstallAppButtonMap = { zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")), office365video: dynamic(() => import("./office365video/components/InstallAppButton")), wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")), + zapier: dynamic(() => import("./zapier/components/InstallAppButton")), jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")), huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), metamask: dynamic(() => import("./metamask/components/InstallAppButton")), diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 60e0f17eab..c7232b556f 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -15,6 +15,7 @@ import * as slackmessaging from "./slackmessaging"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; import * as wipemycalother from "./wipemycalother"; +import * as zapier from "./zapier"; import * as zoomvideo from "./zoomvideo"; const appStore = { @@ -36,6 +37,7 @@ const appStore = { wipemycalother, metamask, giphy, + zapier, }; export default appStore; diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts index 210531a443..5fb28a6414 100644 --- a/packages/app-store/metadata.ts +++ b/packages/app-store/metadata.ts @@ -14,6 +14,7 @@ import { metadata as slackmessaging } from "./slackmessaging/_metadata"; import { metadata as stripepayment } from "./stripepayment/_metadata"; import { metadata as tandemvideo } from "./tandemvideo/_metadata"; import { metadata as wipemycalother } from "./wipemycalother/_metadata"; +import { metadata as zapier } from "./zapier/_metadata"; import { metadata as zoomvideo } from "./zoomvideo/_metadata"; export const appStoreMetadata = { @@ -34,6 +35,7 @@ export const appStoreMetadata = { wipemycalother, metamask, giphy, + zapier, }; export default appStoreMetadata; diff --git a/packages/app-store/zapier/README.md b/packages/app-store/zapier/README.md new file mode 100644 index 0000000000..f00a3752c1 --- /dev/null +++ b/packages/app-store/zapier/README.md @@ -0,0 +1,71 @@ + + + +# Setting up Zapier Integration + +If you run it on localhost, check out the [additional information](https://github.com/CarinaWolli/cal.com/edit/feat/zapier-app/packages/app-store/zapier/README.md#localhost) below. + +1. Create [Zapier Account](https://zapier.com/sign-up?next=https%3A%2F%2Fdeveloper.zapier.com%2F) +2. If not redirected to developer account, go to: [Zapier Developer Account](https://developer.zapier.com) +3. Click **Start a Zapier Integration** +4. Create Integration + - Name: Cal.com + - Description: Cal.com is a scheduling infrastructure for absolutely everyone. + - Intended Audience: Private + - Role: choose whatever is appropriate + - Category: Calendar + +## Authentication + +1. Go to Authentication, choose Api key and click save +2. Click Add Fields + - Key: apiKey + - Check the box ‘is this field required?’ +3. Configure a Test + - Test: GET ``````/api/integrations/zapier/listBookings + - URL Params + - apiKey: {{bundle.authData.apiKey}} +4. Test your authentication —> First you have to install Zapier in the Cal.com App Store and generate an API key, use this API key to test your authentication (only zapier Api key works) + +## Triggers + +Booking created, Booking rescheduled, Booking cancelled + +### Booking created + +1. Settings + - Key: booking_created + - Name: Booking created + - Noun: Booking + - Description: Triggers when a new booking is created +2. API Configuration (apiKey is set automatically, leave it like it is): + - Trigger Type: REST Hook + - Subscribe: POST ``````/api/integrations/zapier/addSubscription + - Request Body + - subscriberUrl: {{bundle.targetUrl}} + - triggerEvent: BOOKING_CREATED + - Unsubscribe: DELETE ``````/api/integrations/zapier/deleteSubscription + - URL Params (in addition to apiKey) + - id: {{bundle.subscribeData.id}} + - PerformList: GET ``````/api/integrations/zapier/listBookings +3. Test your API request + +Create the other two triggers (booking rescheduled, booking cancelled) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created) + +### Testing integration + +Use the sharing link under Manage → Sharing to create your first Cal.com trigger in Zapier + +## Localhost + +Localhost urls can not be used as the base URL for api endpoints + +Possible solution: using [https://ngrok.com/](https://ngrok.com/) + +1. Create Account +2. [Download](https://ngrok.com/download) gnork and start a tunnel to your running localhost + - Use forwarding url as your baseUrl for the URL endpoints diff --git a/packages/app-store/zapier/README.mdx b/packages/app-store/zapier/README.mdx new file mode 100644 index 0000000000..6246dc6310 --- /dev/null +++ b/packages/app-store/zapier/README.mdx @@ -0,0 +1,5 @@ +Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking is created, rescheduled or cancelled. + +
+**After Installation:** You lost your generated API key? Here you can generate a new key and find all information +on how to use the installed app: [Zapier App Setup](http://localhost:3000/apps/setup/zapier) diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts new file mode 100644 index 0000000000..6273c2d17e --- /dev/null +++ b/packages/app-store/zapier/_metadata.ts @@ -0,0 +1,25 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Zapier", + description: _package.description, + installed: true, + category: "other", + imageSrc: "/api/app-store/zapier/icon.svg", + logo: "/api/app-store/zapier/icon.svg", + publisher: "Cal.com", + rating: 0, + reviews: 0, + slug: "zapier", + title: "Zapier", + trending: true, + type: "zapier_other", + url: "https://cal.com/apps/zapier", + variant: "other", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/zapier/api/add.ts b/packages/app-store/zapier/api/add.ts new file mode 100644 index 0000000000..0c01b593cc --- /dev/null +++ b/packages/app-store/zapier/api/add.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + const appType = "zapier_other"; + try { + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + userId: req.session.user.id, + }, + }); + if (alreadyInstalled) { + throw new Error("Already installed"); + } + const installation = await prisma.credential.create({ + data: { + type: appType, + key: {}, + userId: req.session.user.id, + appId: "zapier", + }, + }); + if (!installation) { + throw new Error("Unable to create user credential for zapier"); + } + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } + + return res.status(200).json({ url: "/apps/setup/zapier" }); +} diff --git a/packages/app-store/zapier/api/index.ts b/packages/app-store/zapier/api/index.ts new file mode 100644 index 0000000000..f3f3d07ad7 --- /dev/null +++ b/packages/app-store/zapier/api/index.ts @@ -0,0 +1,4 @@ +export { default as add } from "./add"; +export { default as listBookings } from "./subscriptions/listBookings"; +export { default as deleteSubscription } from "./subscriptions/deleteSubscription"; +export { default as addSubscription } from "./subscriptions/addSubscription"; diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts new file mode 100644 index 0000000000..d1e32c6cba --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const { subscriberUrl, triggerEvent } = req.body; + + if (req.method === "POST") { + try { + const createSubscription = await prisma.webhook.create({ + data: { + id: v4(), + userId: validKey.userId, + eventTriggers: [triggerEvent], + subscriberUrl, + active: true, + appId: "zapier", + }, + }); + res.status(200).json(createSubscription); + } catch (error) { + return res.status(500).json({ message: "Could not create subscription." }); + } + } +} diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts new file mode 100644 index 0000000000..52084db6c2 --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const id = req.query.id as string; + + if (req.method === "DELETE") { + await prisma.webhook.delete({ + where: { + id, + }, + }); + res.status(204).json({ message: "Subscription is deleted." }); + } +} diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts new file mode 100644 index 0000000000..8751b06bea --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + if (req.method === "GET") { + try { + const bookings = await prisma.booking.findMany({ + take: 3, + where: { + userId: validKey.userId, + }, + select: { + description: true, + startTime: true, + endTime: true, + title: true, + location: true, + cancellationReason: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + }, + }); + + res.status(201).json(bookings); + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Unable to get bookings." }); + } + } +} diff --git a/packages/app-store/zapier/components/InstallAppButton.tsx b/packages/app-store/zapier/components/InstallAppButton.tsx new file mode 100644 index 0000000000..ee3e1d1048 --- /dev/null +++ b/packages/app-store/zapier/components/InstallAppButton.tsx @@ -0,0 +1,18 @@ +import type { InstallAppButtonProps } from "@calcom/app-store/types"; + +import useAddAppMutation from "../../_utils/useAddAppMutation"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("zapier_other"); + + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/zapier/components/icon.tsx b/packages/app-store/zapier/components/icon.tsx new file mode 100644 index 0000000000..b4c2401ca1 --- /dev/null +++ b/packages/app-store/zapier/components/icon.tsx @@ -0,0 +1,15 @@ +export default function Icon() { + return ( + + + + ); +} diff --git a/packages/app-store/zapier/components/index.ts b/packages/app-store/zapier/components/index.ts new file mode 100644 index 0000000000..5f2c7965c9 --- /dev/null +++ b/packages/app-store/zapier/components/index.ts @@ -0,0 +1,3 @@ +export { default as InstallAppButton } from "./InstallAppButton"; +export { default as ZapierSetup } from "./zapierSetup"; +export { default as Icon } from "./icon"; diff --git a/packages/app-store/zapier/components/zapierSetup.tsx b/packages/app-store/zapier/components/zapierSetup.tsx new file mode 100644 index 0000000000..b981e06e1d --- /dev/null +++ b/packages/app-store/zapier/components/zapierSetup.tsx @@ -0,0 +1,121 @@ +import { ClipboardCopyIcon } from "@heroicons/react/solid"; +import { Trans } from "next-i18next"; +import Link from "next/link"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import { Button } from "@calcom/ui"; +import Loader from "@calcom/web/components/Loader"; +import { Tooltip } from "@calcom/web/components/Tooltip"; + +import Icon from "./icon"; + +interface IZapierSetupProps { + trpc: any; +} + +const ZAPIER = "zapier"; + +export default function ZapierSetup(props: IZapierSetupProps) { + const { trpc } = props; + const [newApiKey, setNewApiKey] = useState(""); + const { t } = useLocale(); + const utils = trpc.useContext(); + const integrations = trpc.useQuery(["viewer.integrations"]); + const oldApiKey = trpc.useQuery(["viewer.apiKeys.findKeyOfType", { appId: ZAPIER }]); + const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete"); + const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.other?.items.find( + (item: { type: string }) => item.type === "zapier_other" + ); + const [credentialId] = zapierCredentials?.credentialIds || [false]; + const showContent = integrations.data && integrations.isSuccess && credentialId; + + async function createApiKey() { + const event = { note: "Zapier", expiresAt: null, appId: ZAPIER }; + const apiKey = await utils.client.mutation("viewer.apiKeys.create", event); + if (oldApiKey.data) { + deleteApiKey.mutate({ + id: oldApiKey.data.id, + }); + } + setNewApiKey(apiKey); + } + + if (integrations.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {showContent ? ( +
+
+
+ +
+
+
{t("setting_up_zapier")}
+ {!newApiKey ? ( + <> +
{t("generate_api_key")}:
+ + + ) : ( + <> +
{t("your_unique_api_key")}
+
+
{newApiKey}
+ + + +
+
+ {t("copy_safe_api_key")} +
+ + )} + +
    + +
  1. Log into your Zapier account and create a new Zap.
  2. +
  3. Select Cal.com as your Trigger app. Also choose a Trigger event.
  4. +
  5. Choose your account and then enter your Unique API Key.
  6. +
  7. Test your Trigger.
  8. +
  9. You're set!
  10. +
    +
+ + + +
+
+
+ ) : ( +
+
{t("install_zapier_app")}
+
+ + + +
+
+ )} +
+ ); +} diff --git a/packages/app-store/zapier/index.ts b/packages/app-store/zapier/index.ts new file mode 100644 index 0000000000..a698d1f5c5 --- /dev/null +++ b/packages/app-store/zapier/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as components from "./components"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/zapier/package.json b/packages/app-store/zapier/package.json new file mode 100644 index 0000000000..1fcd4ab001 --- /dev/null +++ b/packages/app-store/zapier/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/zapier", + "version": "0.0.0", + "main": "./index.ts", + "description": "Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking was created, rescheduled or cancled.", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/zapier/static/icon.svg b/packages/app-store/zapier/static/icon.svg new file mode 100644 index 0000000000..237b13e5c5 --- /dev/null +++ b/packages/app-store/zapier/static/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ee/lib/api/findValidApiKey.ts b/packages/ee/lib/api/findValidApiKey.ts new file mode 100644 index 0000000000..2aeeb7c78d --- /dev/null +++ b/packages/ee/lib/api/findValidApiKey.ts @@ -0,0 +1,32 @@ +import { hashAPIKey } from "@calcom/ee/lib/api/apiKeys"; +import prisma from "@calcom/prisma"; + +const findValidApiKey = async (apiKey: string, appId?: string) => { + const hashedKey = hashAPIKey(apiKey.substring(process.env.API_KEY_PREFIX?.length || 0)); + + const validKey = await prisma.apiKey.findFirst({ + where: { + AND: [ + { + hashedKey, + }, + { + appId, + }, + ], + OR: [ + { + expiresAt: { + gte: new Date(Date.now()), + }, + }, + { + expiresAt: null, + }, + ], + }, + }); + return validKey; +}; + +export default findValidApiKey; diff --git a/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql b/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql new file mode 100644 index 0000000000..b94dcd9924 --- /dev/null +++ b/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "appId" TEXT; + +-- AlterTable +ALTER TABLE "Webhook" ADD COLUMN "appId" TEXT; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f67cf87331..deb9e92aaa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -387,6 +387,8 @@ model Webhook { eventTriggers WebhookTriggerEvents[] user User? @relation(fields: [userId], references: [id], onDelete: Cascade) eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? } model Impersonations { @@ -407,6 +409,8 @@ model ApiKey { lastUsedAt DateTime? hashedKey String @unique() user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? } model HashedLink { @@ -464,4 +468,6 @@ model App { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt credentials Credential[] + Webhook Webhook[] + ApiKey ApiKey[] } diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index c233366ccf..8942b96440 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -84,6 +84,7 @@ async function main() { api_key: process.env.GIPHY_API_KEY, }); } + await createApp("zapier", "zapier", ["other"], "zapier_other"); // Web3 apps await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video"); await createApp("metamask", "metamask", ["web3"], "metamask_web3"); diff --git a/packages/prisma/zod/webhook.ts b/packages/prisma/zod/webhook.ts index c6a86f1dff..ec61dcd5e1 100755 --- a/packages/prisma/zod/webhook.ts +++ b/packages/prisma/zod/webhook.ts @@ -1,7 +1,7 @@ import * as z from "zod" import * as imports from "../zod-utils" import { WebhookTriggerEvents } from "@prisma/client" -import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index" +import { CompleteUser, UserModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index" export const _WebhookModel = z.object({ id: z.string(), @@ -12,11 +12,13 @@ export const _WebhookModel = z.object({ createdAt: z.date(), active: z.boolean(), eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(), + appId: z.string().nullish(), }) export interface CompleteWebhook extends z.infer { user?: CompleteUser | null eventType?: CompleteEventType | null + app?: CompleteApp | null } /** @@ -27,4 +29,5 @@ export interface CompleteWebhook extends z.infer { export const WebhookModel: z.ZodSchema = z.lazy(() => _WebhookModel.extend({ user: UserModel.nullish(), eventType: EventTypeModel.nullish(), + app: AppModel.nullish(), })) From fa1ca5fba00a5fde7b7e45c561f0ee1e8789c3fa Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 4 May 2022 00:40:01 +0100 Subject: [PATCH 02/55] Slack Signature Verification (#2667) * Slack Verify * Adding await * Update slackVerify.ts Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars --- .../slackmessaging/api/commandHandler.ts | 3 +- .../slackmessaging/api/interactiveHandler.ts | 2 ++ .../slackmessaging/lib/slackVerify.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 packages/app-store/slackmessaging/lib/slackVerify.ts diff --git a/packages/app-store/slackmessaging/api/commandHandler.ts b/packages/app-store/slackmessaging/api/commandHandler.ts index 57845cb3ec..6b78eecd17 100644 --- a/packages/app-store/slackmessaging/api/commandHandler.ts +++ b/packages/app-store/slackmessaging/api/commandHandler.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { showCreateEventMessage, showTodayMessage } from "../lib"; import showLinksMessage from "../lib/showLinksMessage"; +import slackVerify from "../lib/slackVerify"; export enum SlackAppCommands { CREATE_EVENT = "create-event", @@ -12,7 +13,7 @@ export enum SlackAppCommands { export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { const command = req.body.command.split("/").pop(); - + await slackVerify(req, res); switch (command) { case SlackAppCommands.CREATE_EVENT: return await showCreateEventMessage(req, res); diff --git a/packages/app-store/slackmessaging/api/interactiveHandler.ts b/packages/app-store/slackmessaging/api/interactiveHandler.ts index 290c4774a1..25c07b16c8 100644 --- a/packages/app-store/slackmessaging/api/interactiveHandler.ts +++ b/packages/app-store/slackmessaging/api/interactiveHandler.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import createEvent from "../lib/actions/createEvent"; +import slackVerify from "../lib/slackVerify"; enum InteractionEvents { CREATE_EVENT = "cal.event.create", @@ -8,6 +9,7 @@ enum InteractionEvents { export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { + await slackVerify(req, res); const payload = JSON.parse(req.body.payload); const actions = payload.view.callback_id; diff --git a/packages/app-store/slackmessaging/lib/slackVerify.ts b/packages/app-store/slackmessaging/lib/slackVerify.ts new file mode 100644 index 0000000000..fc43b5020b --- /dev/null +++ b/packages/app-store/slackmessaging/lib/slackVerify.ts @@ -0,0 +1,36 @@ +import { createHmac } from "crypto"; +import dayjs from "dayjs"; +import { NextApiRequest, NextApiResponse } from "next"; +import { stringify } from "querystring"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; + +let signingSecret = ""; + +export default async function slackVerify(req: NextApiRequest, res: NextApiResponse) { + const body = req.body; + const timeStamp = req.headers["x-slack-request-timestamp"] as string; // Always returns a string and not a string[] + const slackSignature = req.headers["x-slack-signature"] as string; + const currentTime = dayjs().unix(); + let { signing_secret } = await getAppKeysFromSlug("slack"); + if (typeof signing_secret === "string") signingSecret = signing_secret; + + if (!timeStamp) { + return res.status(400).json({ message: "Missing X-Slack-Request-Timestamp header" }); + } + + if (!signingSecret) { + return res.status(400).json({ message: "Missing Slack's signing_secret" }); + } + + if (Math.abs(currentTime - parseInt(timeStamp)) > 60 * 5) { + return res.status(400).json({ message: "Request is too old" }); + } + + const signature_base = `v0:${timeStamp}:${stringify(body)}`; + const signed_sig = "v0=" + createHmac("sha256", signingSecret).update(signature_base).digest("hex"); + + if (signed_sig !== slackSignature) { + return res.status(400).json({ message: "Invalid signature" }); + } +} From 2cb663cd6aa8fe3a1cf3acd1c3ba6a7c1b0cf33a Mon Sep 17 00:00:00 2001 From: alannnc Date: Tue, 3 May 2022 23:06:20 -0500 Subject: [PATCH 03/55] feature/space-booking-app (#2673) * Reschedule for dynamic events * Fix lint * feature/space-booking-app initial commit * added loom video and fixes for main branch * Revert previous commmit * Renames spacebookingother to spacebooking * Type and perf improvements * Updated comment * Update seed-app-store.ts Co-authored-by: zomars --- apps/web/pages/success.tsx | 484 ++++++++++-------- packages/app-store/apiHandlers.tsx | 1 + packages/app-store/components.tsx | 1 + packages/app-store/index.ts | 2 + packages/app-store/metadata.ts | 2 + packages/app-store/spacebooking/README.mdx | 10 + packages/app-store/spacebooking/_metadata.ts | 26 + packages/app-store/spacebooking/api/add.ts | 43 ++ packages/app-store/spacebooking/api/index.ts | 1 + .../components/FullScreenDialog.tsx | 30 ++ .../components/InstallAppButton.tsx | 17 + .../components/SpaceBookingSuccessPage.tsx | 230 +++++++++ .../spacebooking/components/index.ts | 5 + packages/app-store/spacebooking/index.ts | 4 + packages/app-store/spacebooking/lib/index.ts | 1 + packages/app-store/spacebooking/package.json | 12 + .../spacebooking/static/spacebooking.webp | Bin 0 -> 4194 bytes packages/prisma/seed-app-store.ts | 2 + packages/ui/Dialog.tsx | 7 +- 19 files changed, 658 insertions(+), 220 deletions(-) create mode 100644 packages/app-store/spacebooking/README.mdx create mode 100644 packages/app-store/spacebooking/_metadata.ts create mode 100644 packages/app-store/spacebooking/api/add.ts create mode 100644 packages/app-store/spacebooking/api/index.ts create mode 100644 packages/app-store/spacebooking/components/FullScreenDialog.tsx create mode 100644 packages/app-store/spacebooking/components/InstallAppButton.tsx create mode 100644 packages/app-store/spacebooking/components/SpaceBookingSuccessPage.tsx create mode 100644 packages/app-store/spacebooking/components/index.ts create mode 100644 packages/app-store/spacebooking/index.ts create mode 100644 packages/app-store/spacebooking/lib/index.ts create mode 100644 packages/app-store/spacebooking/package.json create mode 100644 packages/app-store/spacebooking/static/spacebooking.webp diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index 224a74d6c3..ccfe1f8427 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -10,21 +10,21 @@ import { GetServerSidePropsContext } from "next"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components"; import { - useIsEmbed, - useEmbedStyles, - useIsBackgroundTransparent, + sdkActionManager, useEmbedNonStylesConfig, + useIsBackgroundTransparent, + useIsEmbed, } from "@calcom/embed-core"; -import { sdkActionManager } from "@calcom/embed-core"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { EmailInput } from "@calcom/ui/form/fields"; -import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull"; +import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull"; import { getEventName } from "@lib/event"; import useTheme from "@lib/hooks/useTheme"; import { isBrandingHidden } from "@lib/isBrandingHidden"; @@ -215,228 +215,258 @@ export default function Success(props: inferSSRProps) const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id))); return ( (isReady && ( -
- - - -
-
- {isSuccessRedirectAvailable(eventType) && eventType.successRedirectUrl ? ( - - ) : null}{" "} -
+ <> +
+ + + +
+
+ {isSuccessRedirectAvailable(eventType) && eventType.successRedirectUrl ? ( + + ) : null}{" "} -
-
+
+
+ {/* SPACE BOOKING APP */} + {props.userHasSpaceBooking && ( + + )} + )) || null ); @@ -499,6 +529,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { notFound: true, }; } + let spaceBookingAvailable = false; + + let userHasSpaceBooking = false; + if (eventType.users[0] && eventType.users[0].id) { + const credential = await prisma.credential.findFirst({ + where: { + type: "spacebooking_other", + userId: eventType.users[0].id, + }, + }); + if (credential && credential.type === "spacebooking_other") { + userHasSpaceBooking = true; + } + } + if (!eventType.users.length && eventType.userId) { // TODO we should add `user User` relation on `EventType` so this extra query isn't needed const user = await prisma.user.findUnique({ @@ -545,6 +590,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { eventType, trpcState: ssr.dehydrate(), dynamicEventName, + userHasSpaceBooking, }, }; } diff --git a/packages/app-store/apiHandlers.tsx b/packages/app-store/apiHandlers.tsx index c6b988041f..b1c6068729 100644 --- a/packages/app-store/apiHandlers.tsx +++ b/packages/app-store/apiHandlers.tsx @@ -15,6 +15,7 @@ export const apiHandlers = { huddle01video: import("./huddle01video/api"), metamask: import("./metamask/api"), giphy: import("./giphy/api"), + spacebookingother: import("./spacebooking/api"), // @todo Until we use DB slugs everywhere zapierother: import("./zapier/api"), }; diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index c36b7a08be..2ca1929f79 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -26,6 +26,7 @@ export const InstallAppButtonMap = { huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), metamask: dynamic(() => import("./metamask/components/InstallAppButton")), giphy: dynamic(() => import("./giphy/components/InstallAppButton")), + spacebookingother: dynamic(() => import("./spacebooking/components/InstallAppButton")), }; export const InstallAppButton = ( diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index c7232b556f..468086c645 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -12,6 +12,7 @@ import * as metamask from "./metamask"; import * as office365calendar from "./office365calendar"; import * as office365video from "./office365video"; import * as slackmessaging from "./slackmessaging"; +import * as spacebooking from "./spacebooking"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; import * as wipemycalother from "./wipemycalother"; @@ -32,6 +33,7 @@ const appStore = { office365video, slackmessaging, stripepayment, + spacebooking, tandemvideo, zoomvideo, wipemycalother, diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts index 5fb28a6414..d8b8ed37e3 100644 --- a/packages/app-store/metadata.ts +++ b/packages/app-store/metadata.ts @@ -11,6 +11,7 @@ import { metadata as metamask } from "./metamask/_metadata"; import { metadata as office365calendar } from "./office365calendar/_metadata"; import { metadata as office365video } from "./office365video/_metadata"; import { metadata as slackmessaging } from "./slackmessaging/_metadata"; +import { metadata as spacebooking } from "./spacebooking/_metadata"; import { metadata as stripepayment } from "./stripepayment/_metadata"; import { metadata as tandemvideo } from "./tandemvideo/_metadata"; import { metadata as wipemycalother } from "./wipemycalother/_metadata"; @@ -30,6 +31,7 @@ export const appStoreMetadata = { office365video, slackmessaging, stripepayment, + spacebooking, tandemvideo, zoomvideo, wipemycalother, diff --git a/packages/app-store/spacebooking/README.mdx b/packages/app-store/spacebooking/README.mdx new file mode 100644 index 0000000000..d829970254 --- /dev/null +++ b/packages/app-store/spacebooking/README.mdx @@ -0,0 +1,10 @@ + + +Looking to honor May 4th? Search no further. Download this app to make your booking success page resemble a long time ago in a galaxy far far away. diff --git a/packages/app-store/spacebooking/_metadata.ts b/packages/app-store/spacebooking/_metadata.ts new file mode 100644 index 0000000000..5199ad95ae --- /dev/null +++ b/packages/app-store/spacebooking/_metadata.ts @@ -0,0 +1,26 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "SpaceBooking", + description: _package.description, + installed: true, + category: "other", + // If using static next public folder, can then be referenced from the base URL (/). + imageSrc: "/api/app-store/spacebooking/spacebooking.webp", + logo: "/api/app-store/spacebooking/spacebooking.webp", + publisher: "Cal.com", + rating: 0, + reviews: 0, + slug: "space-booking", + title: "SpaceBooking", + trending: true, + type: "spacebooking_other", + url: "https://cal.com/apps/spacebooking", + variant: "other", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/spacebooking/api/add.ts b/packages/app-store/spacebooking/api/add.ts new file mode 100644 index 0000000000..d185f6248b --- /dev/null +++ b/packages/app-store/spacebooking/api/add.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +/** + * @param req + * @param res + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + const appType = "spacebooking_other"; + try { + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + userId: req.session.user.id, + }, + }); + if (alreadyInstalled) { + throw new Error("Already installed"); + } + const installation = await prisma.credential.create({ + data: { + type: appType, + key: {}, + userId: req.session.user.id, + appId: "space-booking", + }, + }); + if (!installation) { + throw new Error("Unable to create user credential for spacebooking"); + } + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } + + return res.status(200).json({ url: "/apps/installed" }); +} diff --git a/packages/app-store/spacebooking/api/index.ts b/packages/app-store/spacebooking/api/index.ts new file mode 100644 index 0000000000..4c0d2ead01 --- /dev/null +++ b/packages/app-store/spacebooking/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/spacebooking/components/FullScreenDialog.tsx b/packages/app-store/spacebooking/components/FullScreenDialog.tsx new file mode 100644 index 0000000000..1229abe75b --- /dev/null +++ b/packages/app-store/spacebooking/components/FullScreenDialog.tsx @@ -0,0 +1,30 @@ +import { XIcon } from "@heroicons/react/solid"; +import { useState } from "react"; + +import { Dialog, DialogContent } from "@calcom/ui/Dialog"; + +const FullScreenDialog = (props: React.PropsWithChildren<{ open: boolean }>) => { + const [open, setOpen] = useState(props.open); + return ( + <> + setOpen(false)}> + +
setOpen(false)} + style={{ + position: "absolute", + top: 16, + right: 16, + zIndex: "inherit", + }}> + +
+ {props.children} +
+
+ + ); +}; + +export { FullScreenDialog }; diff --git a/packages/app-store/spacebooking/components/InstallAppButton.tsx b/packages/app-store/spacebooking/components/InstallAppButton.tsx new file mode 100644 index 0000000000..bb535357bd --- /dev/null +++ b/packages/app-store/spacebooking/components/InstallAppButton.tsx @@ -0,0 +1,17 @@ +import useAddAppMutation from "../../_utils/useAddAppMutation"; +import { InstallAppButtonProps } from "../../types"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("spacebooking_other"); + + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/spacebooking/components/SpaceBookingSuccessPage.tsx b/packages/app-store/spacebooking/components/SpaceBookingSuccessPage.tsx new file mode 100644 index 0000000000..5fc8b77b92 --- /dev/null +++ b/packages/app-store/spacebooking/components/SpaceBookingSuccessPage.tsx @@ -0,0 +1,230 @@ +import { useEffect } from "react"; + +import { FullScreenDialog } from "./FullScreenDialog"; + +const SpaceBookingSuccessPage = (props: { what: string; where: string; when: string; open: boolean }) => { + const { what, where, when, open } = props; + + useEffect(() => { + const canvas = document.getElementById("canvas") as HTMLCanvasElement; + const c = canvas.getContext("2d") as CanvasRenderingContext2D; + + let w: number; + let h: number; + + const setCanvasExtents = () => { + w = document.body.clientWidth; + h = document.body.clientHeight; + canvas.width = Math.min(1600, w); + canvas.height = Math.min(900, h); + }; + + setCanvasExtents(); + + const crawl = document.getElementById("crawl") as HTMLDivElement; + const crawlContent = document.getElementById("crawl-content") as HTMLDivElement; + const crawlContentStyle = crawlContent.style; + + // start crawl at bottom of 3d plane + let crawlPos = crawl.clientHeight; + + const makeStars = (count: number) => { + const out = []; + for (let i = 0; i < count; i++) { + const s = { + x: Math.random() * 1600 - 800, + y: Math.random() * 900 - 450, + z: Math.random() * 1000, + }; + out.push(s); + } + return out; + }; + + let stars = makeStars(2000); + + window.onresize = () => { + setCanvasExtents(); + }; + + const clear = () => { + c.fillStyle = "black"; + c.fillRect(0, 0, canvas.width, canvas.height); + }; + + const putPixel = (x: number, y: number, brightness: number) => { + const intensity = brightness * 255; + const rgb = "rgb(" + intensity + "," + intensity + "," + intensity + ")"; + c.fillStyle = rgb; + c.fillRect(x, y, 1, 1); + }; + + const moveStars = (distance: number) => { + const count = stars.length; + for (var i = 0; i < count; i++) { + const s = stars[i]; + s.z -= distance; + if (s.z <= 1) { + s.z += 999; + } + } + }; + + const moveCrawl = (distance: number) => { + crawlPos -= distance; + crawlContentStyle.top = crawlPos + "px"; + + // if we've scrolled all content past the top edge + // of the plane, reposition content at bottom of plane + if (crawlPos < -crawlContent.clientHeight) { + crawlPos = crawl.clientHeight; + } + }; + + const paintStars = () => { + const cx = canvas.width / 2; + const cy = canvas.height / 2; + + const count = stars.length; + for (var i = 0; i < count; i++) { + const star = stars[i]; + + const x = cx + star.x / (star.z * 0.001); + const y = cy + star.y / (star.z * 0.001); + + if (x < 0 || x >= w || y < 0 || y >= h) { + continue; + } + + const d = star.z / 1000.0; + const b = 1 - d * d; + + putPixel(x, y, b); + } + }; + + let prevTime: number; + const init = (time: number) => { + prevTime = time; + requestAnimationFrame(tick); + }; + + const tick = (time: number) => { + let elapsed = time - prevTime; + prevTime = time; + + moveStars(elapsed * 0.02); + + // time-scale of crawl, increase factor to go faster + moveCrawl(elapsed * 0.06); + + clear(); + paintStars(); + + requestAnimationFrame(tick); + }; + + requestAnimationFrame(init); + }, []); + + const episodeDetails = [ + { number: "IV", name: "A new hope" }, + { number: "V", name: "The Empire" }, + { number: "VI", name: "Return of the Jedi" }, + { number: "I", name: "The Phantom menace" }, + { number: "II", name: "Attack of the Clones" }, + { number: "III", name: "Revenge of the Sith" }, + ]; + const randomEpisodeNumber = episodeDetails[Math.floor(Math.random() * episodeDetails.length)]; + + const css = ` + #crawl-container { + perspective: calc(100vh * 0.4); + } + #crawl { + color: #f5c91c; + position: absolute; + width: 110%; + left: -5%; + bottom: -5%; + height: 200%; + overflow: hidden; + + transform: rotate3d(1, 0, 0, 45deg); + transform-origin: 50% 100%; + + mask-image: linear-gradient( + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0.66), + rgba(0, 0, 0, 1) + ); + + -webkit-mask-image: -webkit-linear-gradient( + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0.66), + rgba(0, 0, 0, 1) + ); + } + + #crawl-content { + font-family: "Roboto"; + font-size: calc(100vw * 0.074); + letter-spacing: 0.09em; + position: absolute; + top: 0px; + left: 0px; + right: 0px; + } + + #crawl p { + text-align: justify; + width: 100%; + margin: 0 0 1.25em 0; + line-height: 1.25em; + } + + #crawl h1 { + font-size: 1em; + margin: 0 0 0.3em 0; + } + + #crawl h2 { + font-size: 1.5em; + margin: 0 0 0.7em 0; + } + + #crawl h1, + #crawl h2 { + text-align: center; + } +`; + + return ( + <> + + +
+
+
+

Episode {randomEpisodeNumber.number}

+

{randomEpisodeNumber.name}

+

{what}

+

+ {when} +
+

+

{where}

+
+
+
+ +
+ + ); +}; + +export default SpaceBookingSuccessPage; diff --git a/packages/app-store/spacebooking/components/index.ts b/packages/app-store/spacebooking/components/index.ts new file mode 100644 index 0000000000..40e82ee54e --- /dev/null +++ b/packages/app-store/spacebooking/components/index.ts @@ -0,0 +1,5 @@ +import dynamic from "next/dynamic"; + +export { default as InstallAppButton } from "./InstallAppButton"; +/** These are like 12kb that not every user needs */ +export const SpaceBookingSuccessPage = dynamic(() => import("./SpaceBookingSuccessPage")); diff --git a/packages/app-store/spacebooking/index.ts b/packages/app-store/spacebooking/index.ts new file mode 100644 index 0000000000..19f36d184d --- /dev/null +++ b/packages/app-store/spacebooking/index.ts @@ -0,0 +1,4 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; +export * as components from "./components"; diff --git a/packages/app-store/spacebooking/lib/index.ts b/packages/app-store/spacebooking/lib/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/app-store/spacebooking/lib/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/app-store/spacebooking/package.json b/packages/app-store/spacebooking/package.json new file mode 100644 index 0000000000..c299e2d79b --- /dev/null +++ b/packages/app-store/spacebooking/package.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/spacebooking", + "version": "0.1.0", + "main": "./index.ts", + "description": "Looking to honor May 4th? Search no further. Install this app to make your booking success page resemble a long time ago in a galaxy far far away.", + "dependencies": {}, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/spacebooking/static/spacebooking.webp b/packages/app-store/spacebooking/static/spacebooking.webp new file mode 100644 index 0000000000000000000000000000000000000000..3da82b8310a0e0c1d065d5cda68f43b38d055129 GIT binary patch literal 4194 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 & { name?: string; clearQueryParamsOnClose?: string[]; @@ -63,7 +65,10 @@ export const DialogContent = React.forwardRef {children} From ec5020ca3d93d5f49506d76aa7443b9fc9c13a0c Mon Sep 17 00:00:00 2001 From: zomars Date: Tue, 3 May 2022 22:30:46 -0600 Subject: [PATCH 04/55] Update index.ts --- packages/app-store/spacebooking/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app-store/spacebooking/index.ts b/packages/app-store/spacebooking/index.ts index 19f36d184d..5373eb04ef 100644 --- a/packages/app-store/spacebooking/index.ts +++ b/packages/app-store/spacebooking/index.ts @@ -1,4 +1,3 @@ export * as api from "./api"; export * as lib from "./lib"; export { metadata } from "./_metadata"; -export * as components from "./components"; From 4f0ee7b0b67961f7c2501b3f643c9225b0f9c47f Mon Sep 17 00:00:00 2001 From: zomars Date: Tue, 3 May 2022 22:38:48 -0600 Subject: [PATCH 05/55] slug fixes --- packages/app-store/huddle01video/_metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/huddle01video/_metadata.ts b/packages/app-store/huddle01video/_metadata.ts index ee68f1ff45..faae97c209 100644 --- a/packages/app-store/huddle01video/_metadata.ts +++ b/packages/app-store/huddle01video/_metadata.ts @@ -18,7 +18,7 @@ export const metadata = { rating: 0, // TODO: placeholder for now, pull this from TrustPilot or G2 reviews: 0, // TODO: placeholder for now, pull this from TrustPilot or G2 category: "web3", - slug: "huddle01_video", + slug: "huddle01", title: "Huddle01", trending: true, isGlobal: false, From bd9b83540b6217984a64aedfa07433eb253127e8 Mon Sep 17 00:00:00 2001 From: zomars Date: Tue, 3 May 2022 22:44:24 -0600 Subject: [PATCH 06/55] Jitsi slug fixes --- packages/app-store/jitsivideo/_metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/jitsivideo/_metadata.ts b/packages/app-store/jitsivideo/_metadata.ts index 497fd382e1..881ed77491 100644 --- a/packages/app-store/jitsivideo/_metadata.ts +++ b/packages/app-store/jitsivideo/_metadata.ts @@ -17,7 +17,7 @@ export const metadata = { rating: 0, // TODO: placeholder for now, pull this from TrustPilot or G2 reviews: 0, // TODO: placeholder for now, pull this from TrustPilot or G2 category: "video", - slug: "jitsi_video", + slug: "jitsi", title: "Jitsi Meet", trending: true, isGlobal: false, From 82f7779a230262a1b57258497eed662e84a46b09 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 4 May 2022 14:45:30 -0400 Subject: [PATCH 07/55] MS Teams title to state that a work/school account is required. (#2677) Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../app-store/office365video/_metadata.ts | 4 +- .../components/AccountDialog.tsx | 43 +++++++++++++++++++ .../components/InstallAppButton.tsx | 12 +++--- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 packages/app-store/office365video/components/AccountDialog.tsx diff --git a/packages/app-store/office365video/_metadata.ts b/packages/app-store/office365video/_metadata.ts index c4825514a5..d56e7752a9 100644 --- a/packages/app-store/office365video/_metadata.ts +++ b/packages/app-store/office365video/_metadata.ts @@ -4,7 +4,7 @@ import { LocationType } from "../locations"; import _package from "./package.json"; export const metadata = { - name: "Microsoft 365/Teams", + name: "Microsoft 365/Teams (Requires work/school account)", description: _package.description, type: "office365_video", imageSrc: "/api/app-store/office365video/icon.svg", @@ -17,7 +17,7 @@ export const metadata = { reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2 category: "video", slug: "msteams", - title: "MS Teams", + title: "MS Teams (Requires work/school account)", trending: true, email: "help@cal.com", locationType: LocationType.Teams, diff --git a/packages/app-store/office365video/components/AccountDialog.tsx b/packages/app-store/office365video/components/AccountDialog.tsx new file mode 100644 index 0000000000..42d5864021 --- /dev/null +++ b/packages/app-store/office365video/components/AccountDialog.tsx @@ -0,0 +1,43 @@ +import Button from "@calcom/ui/Button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogProps, +} from "@calcom/ui/Dialog"; + +import useAddAppMutation from "../../_utils/useAddAppMutation"; + +export function AccountDialog(props: DialogProps) { + const mutation = useAddAppMutation("office365_calendar"); + return ( + + + + + + { + props.onOpenChange?.(false); + }} + asChild> + + + + + + + + ); +} + +export default AccountDialog; diff --git a/packages/app-store/office365video/components/InstallAppButton.tsx b/packages/app-store/office365video/components/InstallAppButton.tsx index aaa8eabb36..778d00423c 100644 --- a/packages/app-store/office365video/components/InstallAppButton.tsx +++ b/packages/app-store/office365video/components/InstallAppButton.tsx @@ -1,18 +1,20 @@ -import type { InstallAppButtonProps } from "@calcom/app-store/types"; +import { useState } from "react"; -import useAddAppMutation from "../../_utils/useAddAppMutation"; +import { InstallAppButtonProps } from "../../types"; +import AddIntegration from "./AccountDialog"; export default function InstallAppButton(props: InstallAppButtonProps) { - const mutation = useAddAppMutation("office365_video"); + const [isModalOpen, setIsModalOpen] = useState(false); return ( <> {props.render({ onClick() { - mutation.mutate(""); + setIsModalOpen(true); }, - loading: mutation.isLoading, + disabled: isModalOpen, })} + ); } From 32348988923a9d9378518f44785f6bcaa711c9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20David=20S=C3=A1nchez=20Gallego?= <50778235+juliansg022@users.noreply.github.com> Date: Mon, 14 Mar 2022 10:34:05 -0500 Subject: [PATCH 08/55] Add restrictions to protect the owners and change their roles --- .../components/team/MemberChangeRoleModal.tsx | 9 +++++--- .../components/team/MemberInvitationModal.tsx | 6 ++++-- apps/web/components/team/MemberListItem.tsx | 21 +++++++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/components/team/MemberChangeRoleModal.tsx b/apps/web/components/team/MemberChangeRoleModal.tsx index e39178992c..bd2a11ddcc 100644 --- a/apps/web/components/team/MemberChangeRoleModal.tsx +++ b/apps/web/components/team/MemberChangeRoleModal.tsx @@ -5,6 +5,7 @@ import React, { SyntheticEvent, useEffect } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; +import { TeamWithMembers } from "@lib/queries/teams"; import { trpc } from "@lib/trpc"; import ModalContainer from "@components/ui/ModalContainer"; @@ -15,10 +16,11 @@ type MembershipRoleOption = { label?: string; }; -const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }]; +const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }]; export default function MemberChangeRoleModal(props: { isOpen: boolean; + team: TeamWithMembers; memberId: number; teamId: number; initialRole: MembershipRole; @@ -48,6 +50,8 @@ export default function MemberChangeRoleModal(props: { }, }); + const memberRole = props.team?.membership.role; + function changeRole(e: SyntheticEvent) { e.preventDefault(); @@ -57,7 +61,6 @@ export default function MemberChangeRoleModal(props: { role: role.value, }); } - return ( <> @@ -76,7 +79,7 @@ export default function MemberChangeRoleModal(props: { {/* - needs dialog to confirm change of ownership */} { + const { members } = props.team; + const owners = members.filter((member) => member["role"] === "OWNER"); + return owners.length; + }; + + const useCurrentUser = () => { + const query = useMeQuery(); + const user = query.data; + return user?.id; + }; + + const currentUser = useCurrentUser(); + const name = props.member.name || (() => { @@ -121,8 +136,9 @@ export default function MemberListItem(props: Props) { - {(props.team.membership.role === MembershipRole.OWNER || - props.team.membership.role === MembershipRole.ADMIN) && ( + {((props.team.membership.role === MembershipRole.OWNER && + (props.member.role !== "OWNER" || (ownersInTeam() > 1 && props.member.id === currentUser))) || + (props.team.membership.role === MembershipRole.ADMIN && props.member.role !== "OWNER")) && ( <> {props.team.membership.role !== MembershipRole.MEMBER && ( - + )} From d6b7311c665b066c1cdccff7bd2bf3a4b6d5ddd5 Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 4 May 2022 15:13:01 -0600 Subject: [PATCH 16/55] Build fixes --- apps/web/ee/pages/settings/teams/[id]/availability.tsx | 3 ++- apps/web/pages/settings/billing.tsx | 3 ++- apps/web/pages/settings/teams/index.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/ee/pages/settings/teams/[id]/availability.tsx b/apps/web/ee/pages/settings/teams/[id]/availability.tsx index 3e22e8d86f..49cba9598c 100644 --- a/apps/web/ee/pages/settings/teams/[id]/availability.tsx +++ b/apps/web/ee/pages/settings/teams/[id]/availability.tsx @@ -5,10 +5,11 @@ import { Alert } from "@calcom/ui/Alert"; import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen"; import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; +import useMeQuery from "@lib/hooks/useMeQuery"; import { trpc } from "@lib/trpc"; import Loader from "@components/Loader"; -import Shell, { useMeQuery } from "@components/Shell"; +import Shell from "@components/Shell"; import Avatar from "@components/ui/Avatar"; export function TeamAvailabilityPage() { diff --git a/apps/web/pages/settings/billing.tsx b/apps/web/pages/settings/billing.tsx index de79b1b34d..4e6df89b60 100644 --- a/apps/web/pages/settings/billing.tsx +++ b/apps/web/pages/settings/billing.tsx @@ -5,9 +5,10 @@ import { useIntercom } from "react-use-intercom"; import Button from "@calcom/ui/Button"; import { useLocale } from "@lib/hooks/useLocale"; +import useMeQuery from "@lib/hooks/useMeQuery"; import SettingsShell from "@components/SettingsShell"; -import Shell, { useMeQuery } from "@components/Shell"; +import Shell from "@components/Shell"; type CardProps = { title: string; description: string; className?: string; children: ReactNode }; const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => ( diff --git a/apps/web/pages/settings/teams/index.tsx b/apps/web/pages/settings/teams/index.tsx index 034f77c180..be391687cf 100644 --- a/apps/web/pages/settings/teams/index.tsx +++ b/apps/web/pages/settings/teams/index.tsx @@ -8,12 +8,13 @@ import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { useLocale } from "@lib/hooks/useLocale"; +import useMeQuery from "@lib/hooks/useMeQuery"; import { trpc } from "@lib/trpc"; import EmptyScreen from "@components/EmptyScreen"; import Loader from "@components/Loader"; import SettingsShell from "@components/SettingsShell"; -import Shell, { useMeQuery } from "@components/Shell"; +import Shell from "@components/Shell"; import TeamCreateModal from "@components/team/TeamCreateModal"; import TeamList from "@components/team/TeamList"; From 09d0f68c4c8c454204e240555181a379de04c703 Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 4 May 2022 16:09:23 -0600 Subject: [PATCH 17/55] Update package.json --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e135829364..fa80e14e15 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "lint:report": "turbo run lint:report", "postinstall": "turbo run postinstall", "pre-commit": "lint-staged", - "predev": "dotenv-checker --schema .env.example --env .env", + "env-check:common": "dotenv-checker --schema .env.example --env .env", + "env-check:app-store": "dotenv-checker --schema .env.appStore.example --env .env.appStore", + "predev": "yarn env-check:common && yarn env-check:app-store ", "prepare": "husky install", "start": "turbo run start --scope=\"@calcom/web\"", "test": "turbo run test", From 6ec2b20a23c299869a7cb927f4dd7a5338072dbd Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 4 May 2022 16:10:51 -0600 Subject: [PATCH 18/55] Separates appStore env file --- .env.appStore.example | 74 +++++++++++++++++++++++++++++++ .env.example | 71 ----------------------------- .gitignore | 1 + packages/prisma/seed-app-store.ts | 2 +- 4 files changed, 76 insertions(+), 72 deletions(-) create mode 100644 .env.appStore.example diff --git a/.env.appStore.example b/.env.appStore.example new file mode 100644 index 0000000000..d18d160153 --- /dev/null +++ b/.env.appStore.example @@ -0,0 +1,74 @@ +# ********** INDEX ********** +# +# - APP STORE +# - DAILY.CO VIDEO +# - GOOGLE CALENDAR/MEET/LOGIN +# - HUBSPOT +# - OFFICE 365 +# - SLACK +# - STRIPE +# - TANDEM +# - ZOOM +# - GIPHY +# - VITAL + +# - APP STORE ********************************************************************************************** +# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ +# - DAILY.CO VIDEO +DAILY_API_KEY= +DAILY_SCALE_PLAN='' + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS='{}' +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - HUBSPOT +# Used for the HubSpot integration +# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret +HUBSPOT_CLIENT_ID="" +HUBSPOT_CLIENT_SECRET="" + +# - OFFICE 365 +# Used for the Office 365 / Outlook.com Calendar / MS Teams integration +# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret +MS_GRAPH_CLIENT_ID= +MS_GRAPH_CLIENT_SECRET= + +# - SLACK +# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +# - STRIPE +NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_... +STRIPE_PRIVATE_KEY= # sk_test_... +STRIPE_WEBHOOK_SECRET= # whsec_... +STRIPE_CLIENT_ID= # ca_... +PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission +PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission + +# - TANDEM +# Used for the Tandem integration -- contact support@tandem.chat for API access. +TANDEM_CLIENT_ID="" +TANDEM_CLIENT_SECRET="" +TANDEM_BASE_URL="https://tandem.chat" + +# - ZOOM +# Used for the Zoom integration +# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret +ZOOM_CLIENT_ID= +ZOOM_CLIENT_SECRET= + +# - GIPHY +# Used for the Giphy integration +# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key +GIPHY_API_KEY= +# ********************************************************************************************************* diff --git a/.env.example b/.env.example index 7820589899..dae85a0ed9 100644 --- a/.env.example +++ b/.env.example @@ -5,16 +5,6 @@ # - SHARED # - NEXTAUTH # - E-MAIL SETTINGS -# - APP STORE -# - DAILY.CO VIDEO -# - GOOGLE CALENDAR/MEET/LOGIN -# - HUBSPOT -# - OFFICE 365 -# - SLACK -# - STRIPE -# - TANDEM -# - ZOOM -# - GIPHY # - LICENSE ************************************************************************************************* # Set this value to 'agree' to accept our license: @@ -115,64 +105,3 @@ EMAIL_SERVER_PASSWORD='' ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_PASSWORD='' # ********************************************************************************************************** - -# - APP STORE ********************************************************************************************** -# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ -# - DAILY.CO VIDEO -DAILY_API_KEY= -DAILY_SCALE_PLAN='' - -# - GOOGLE CALENDAR/MEET/LOGIN -# Needed to enable Google Calendar integration and Login with Google -# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials -GOOGLE_API_CREDENTIALS='{}' -# To enable Login with Google you need to: -# 1. Set `GOOGLE_API_CREDENTIALS` above -# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` -# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance -# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications -GOOGLE_LOGIN_ENABLED=false - -# - HUBSPOT -# Used for the HubSpot integration -# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret -HUBSPOT_CLIENT_ID="" -HUBSPOT_CLIENT_SECRET="" - -# - OFFICE 365 -# Used for the Office 365 / Outlook.com Calendar / MS Teams integration -# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret -MS_GRAPH_CLIENT_ID= -MS_GRAPH_CLIENT_SECRET= - -# - SLACK -# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret -SLACK_SIGNING_SECRET= -SLACK_CLIENT_ID= -SLACK_CLIENT_SECRET= - -# - STRIPE -NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_... -STRIPE_PRIVATE_KEY= # sk_test_... -STRIPE_WEBHOOK_SECRET= # whsec_... -STRIPE_CLIENT_ID= # ca_... -PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission -PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission - -# - TANDEM -# Used for the Tandem integration -- contact support@tandem.chat for API access. -TANDEM_CLIENT_ID="" -TANDEM_CLIENT_SECRET="" -TANDEM_BASE_URL="https://tandem.chat" - -# - ZOOM -# Used for the Zoom integration -# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret -ZOOM_CLIENT_ID= -ZOOM_CLIENT_SECRET= - -# - GIPHY -# Used for the Giphy integration -# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key -GIPHY_API_KEY= -# ********************************************************************************************************* diff --git a/.gitignore b/.gitignore index 53ec825669..3df22db260 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ yarn-error.log* .env.production.local .env.* !.env.example +!.env.appStore.example # vercel .vercel diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 18d29cb54f..fe5a4621dd 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client"; import prisma from "."; -require("dotenv").config({ path: "../../.env" }); +require("dotenv").config({ path: "../../.env.appStore" }); async function createApp( slug: Prisma.AppCreateInput["slug"], From df7abdfc0613a3bef06310a3d1f82ce2a7038ac9 Mon Sep 17 00:00:00 2001 From: alannnc Date: Wed, 4 May 2022 20:03:36 -0500 Subject: [PATCH 19/55] Fix/reschedule on dynamic events (#2657) * Reschedule for dynamic events * Fix lint * Handling attendee calendar event cancellation Co-authored-by: zomars --- apps/web/pages/api/book/request-reschedule.ts | 74 ++++++++++++++----- .../core/builders/CalendarEvent/builder.ts | 63 ++++++++++++---- .../core/builders/CalendarEvent/director.ts | 32 +++++++- 3 files changed, 135 insertions(+), 34 deletions(-) diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts index a4c96646bb..ba57e49ff1 100644 --- a/apps/web/pages/api/book/request-reschedule.ts +++ b/apps/web/pages/api/book/request-reschedule.ts @@ -1,4 +1,4 @@ -import { BookingStatus, User, Booking, Attendee, BookingReference } from "@prisma/client"; +import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client"; import dayjs from "dayjs"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "next-auth/react"; @@ -29,7 +29,7 @@ const rescheduleSchema = z.object({ rescheduleReason: z.string().optional(), }); -const findUserOwnerByUserId = async (userId: number) => { +const findUserDataByUserId = async (userId: number) => { return await prisma.user.findUnique({ rejectOnNotFound: true, where: { @@ -57,10 +57,10 @@ const handler = async ( bookingId, rescheduleReason: cancellationReason, }: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body; - let userOwner: Awaited>; + let userOwner: Awaited>; try { if (session?.user?.id) { - userOwner = await findUserOwnerByUserId(session?.user.id); + userOwner = await findUserDataByUserId(session?.user.id); } else { return res.status(501); } @@ -76,6 +76,10 @@ const handler = async ( location: true, attendees: true, references: true, + userId: true, + dynamicEventSlugRef: true, + dynamicGroupSlugRef: true, + destinationCalendar: true, }, rejectOnNotFound: true, where: { @@ -88,18 +92,21 @@ const handler = async ( }, }); - if (bookingToReschedule && bookingToReschedule.eventTypeId && userOwner) { - const event = await prisma.eventType.findFirst({ - select: { - title: true, - users: true, - schedulingType: true, - }, - rejectOnNotFound: true, - where: { - id: bookingToReschedule.eventTypeId, - }, - }); + if (bookingToReschedule && userOwner) { + let event: Partial = {}; + if (bookingToReschedule.eventTypeId) { + event = await prisma.eventType.findFirst({ + select: { + title: true, + users: true, + schedulingType: true, + }, + rejectOnNotFound: true, + where: { + id: bookingToReschedule.eventTypeId, + }, + }); + } await prisma.booking.update({ where: { id: bookingToReschedule.id, @@ -136,7 +143,7 @@ const handler = async ( const builder = new CalendarEventBuilder(); builder.init({ title: bookingToReschedule.title, - type: event.title, + type: event && event.title ? event.title : bookingToReschedule.title, startTime: bookingToReschedule.startTime.toISOString(), endTime: bookingToReschedule.endTime.toISOString(), attendees: usersToPeopleType( @@ -149,9 +156,13 @@ const handler = async ( const director = new CalendarEventDirector(); director.setBuilder(builder); - director.setExistingBooking(bookingToReschedule as unknown as Booking); + director.setExistingBooking(bookingToReschedule); director.setCancellationReason(cancellationReason); - await director.buildForRescheduleEmail(); + if (!!event) { + await director.buildWithoutEventTypeForRescheduleEmail(); + } else { + await director.buildForRescheduleEmail(); + } // Handling calendar and videos cancellation // This can set previous time as available, until virtual calendar is done @@ -174,6 +185,31 @@ const handler = async ( } }); + // Updating attendee destinationCalendar if required + if ( + bookingToReschedule.destinationCalendar && + bookingToReschedule.destinationCalendar.userId && + bookingToReschedule.destinationCalendar.integration.endsWith("_calendar") + ) { + const { destinationCalendar } = bookingToReschedule; + if (destinationCalendar.userId) { + const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter( + (ref) => !!credentialsMap.get(ref.type) + ); + const attendeeData = await findUserDataByUserId(destinationCalendar.userId); + const attendeeCredentialsMap = new Map(); + attendeeData.credentials.forEach((credential) => { + attendeeCredentialsMap.set(credential.type, credential); + }); + bookingRefsFiltered.forEach((bookingRef) => { + if (bookingRef.uid) { + const calendar = getCalendar(attendeeCredentialsMap.get(destinationCalendar.integration)); + calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent); + } + }); + } + } + // Send emails await sendRequestRescheduleEmail(builder.calendarEvent, { rescheduleLink: builder.rescheduleLink, diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts index 11c55cdce6..4469708043 100644 --- a/packages/core/builders/CalendarEvent/builder.ts +++ b/packages/core/builders/CalendarEvent/builder.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import { Prisma, Booking } from "@prisma/client"; import dayjs from "dayjs"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; @@ -80,7 +80,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { } users.push(eventTypeUser); } - this.users = users; + this.setUsers(users); } public buildAttendeesList() { @@ -273,17 +273,54 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { this.calendarEvent.cancellationReason = cancellationReason; } - public buildRescheduleLink(originalBookingUId: string) { - if (!this.eventType) { - throw new Error("Run buildEventObjectFromInnerClass before this function"); - } - const isTeam = !!this.eventType.teamId; + public setUsers(users: User[]) { + this.users = users; + } - const queryParams = new URLSearchParams(); - queryParams.set("rescheduleUid", `${originalBookingUId}`); - const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${ - isTeam ? `/team/${this.eventType.team?.slug}` : this.users[0].username - }/${this.eventType.slug}?${queryParams.toString()}`; - this.rescheduleLink = rescheduleLink; + public async setUsersFromId(userId: User["id"]) { + let resultUser: User | null; + try { + resultUser = await prisma.user.findUnique({ + rejectOnNotFound: true, + where: { + id: userId, + }, + ...userSelect, + }); + this.setUsers([resultUser]); + } catch (error) { + throw new Error("getUsersById.users.notFound"); + } + } + + public buildRescheduleLink(booking: Partial, eventType?: CalendarEventBuilder["eventType"]) { + try { + if (!booking) { + throw new Error("Parameter booking is required to build reschedule link"); + } + const isTeam = !!eventType && !!eventType.teamId; + const isDynamic = booking?.dynamicEventSlugRef && booking?.dynamicGroupSlugRef; + + let slug = ""; + if (isTeam && eventType?.team?.slug) { + slug = `/team/${eventType.team?.slug}`; + } else if (isDynamic) { + const dynamicSlug = isDynamic ? `${booking.dynamicGroupSlugRef}/${booking.dynamicEventSlugRef}` : ""; + slug = dynamicSlug; + } else if (eventType?.slug) { + slug = `${this.users[0].username}/${eventType.slug}`; + } + + const queryParams = new URLSearchParams(); + queryParams.set("rescheduleUid", `${booking.uid}`); + slug = `${slug}?${queryParams.toString()}`; + + const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${slug}`; + this.rescheduleLink = rescheduleLink; + } catch (error) { + if (error instanceof Error) { + throw new Error(`buildRescheduleLink.error: ${error.message}`); + } + } } } diff --git a/packages/core/builders/CalendarEvent/director.ts b/packages/core/builders/CalendarEvent/director.ts index b33311b8bf..3b9e7a3c2b 100644 --- a/packages/core/builders/CalendarEvent/director.ts +++ b/packages/core/builders/CalendarEvent/director.ts @@ -11,7 +11,21 @@ export class CalendarEventDirector { this.builder = builder; } - public setExistingBooking(booking: Booking) { + public setExistingBooking( + booking: Pick< + Booking, + | "id" + | "uid" + | "title" + | "startTime" + | "endTime" + | "eventTypeId" + | "userId" + | "dynamicEventSlugRef" + | "dynamicGroupSlugRef" + | "location" + > + ) { this.existingBooking = booking; } @@ -29,9 +43,23 @@ export class CalendarEventDirector { this.builder.setCancellationReason(this.cancellationReason); this.builder.setDescription(this.builder.eventType.description); this.builder.setNotes(this.existingBooking.description); - this.builder.buildRescheduleLink(this.existingBooking.uid); + this.builder.buildRescheduleLink(this.existingBooking, this.builder.eventType); } else { throw new Error("buildForRescheduleEmail.missing.params.required"); } } + + public async buildWithoutEventTypeForRescheduleEmail() { + if (this.existingBooking && this.existingBooking.userId && this.existingBooking.uid) { + await this.builder.setUsersFromId(this.existingBooking.userId); + this.builder.buildAttendeesList(); + this.builder.setLocation(this.existingBooking.location); + this.builder.setUId(this.existingBooking.uid); + this.builder.setCancellationReason(this.cancellationReason); + this.builder.setDescription(this.existingBooking.description); + await this.builder.buildRescheduleLink(this.existingBooking); + } else { + throw new Error("buildWithoutEventTypeForRescheduleEmail.missing.params.required"); + } + } } From 60146ed2c599b8c5ac6f27df1f7df1bca1ad6749 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 5 May 2022 11:32:08 +0200 Subject: [PATCH 20/55] fix redirect to setup page (#2683) Co-authored-by: CarinaWolli --- packages/app-store/zapier/README.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app-store/zapier/README.mdx b/packages/app-store/zapier/README.mdx index 6246dc6310..52d0240a08 100644 --- a/packages/app-store/zapier/README.mdx +++ b/packages/app-store/zapier/README.mdx @@ -2,4 +2,5 @@ Workflow automation for everyone. Use the Cal.com Zapier app to trigger your wor
**After Installation:** You lost your generated API key? Here you can generate a new key and find all information -on how to use the installed app: [Zapier App Setup](http://localhost:3000/apps/setup/zapier) +on how to use the installed app: Zapier App Setup + From 174ed9f6d1e6753fb46909553a681deabd74a484 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 5 May 2022 19:59:49 +0530 Subject: [PATCH 21/55] Embed Snippet Generator (#2597) * Add support to dynamically change the theme * Add Embed UI in app * Update UI as per Figma * Dynamicaly update Embed Code * Get differnet modes working in preview * Support Embed on EventType Edit, Team Link Fix and Mobile unsupported * Fix auto theme switch in Embed Snippet generator * Fix types * Self Review fixes * Remove Embed from App section * Move get query after the middleware to let middleware work on it * Add sandboxes in the document * Add error handling for embed loading * Fix types * Update snapshots and fix bug identified by tests * UI Fixes * Add Embed Tests * Respond in preview to width and height Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/docs/components/Anchor.tsx | 21 + apps/docs/pages/integrations/embed.mdx | 61 +- apps/web/components/Embed.tsx | 905 ++++++++++++++++++ apps/web/components/NavTabs.tsx | 42 +- apps/web/lib/hooks/useTheme.tsx | 2 +- apps/web/next.config.js | 1 + apps/web/pages/apps/installed.tsx | 82 -- apps/web/pages/event-types/[type].tsx | 6 + apps/web/pages/event-types/index.tsx | 13 +- .../playwright/embed-code-generator.test.ts | 188 ++++ apps/web/public/static/locales/en/common.json | 5 + apps/web/server/routers/viewer/eventTypes.tsx | 34 + packages/embeds/embed-core/README.md | 11 +- packages/embeds/embed-core/index.html | 1 + packages/embeds/embed-core/package.json | 7 +- .../availability-page-1-chromium-darwin.png | Bin 221189 -> 222332 bytes .../availability-page-1-firefox-darwin.png | Bin 174851 -> 203876 bytes .../availability-page-1-webkit-darwin.png | Bin 1010886 -> 1172414 bytes .../availability-page-2-chromium-darwin.png | Bin 225511 -> 224808 bytes .../availability-page-2-firefox-darwin.png | Bin 187157 -> 205219 bytes .../availability-page-2-webkit-darwin.png | Bin 1019763 -> 1177262 bytes .../booking-page-chromium-darwin.png | Bin 239371 -> 238720 bytes .../booking-page-firefox-darwin.png | Bin 231465 -> 222016 bytes .../booking-page-webkit-darwin.png | Bin 1293711 -> 1233122 bytes .../event-types-list-firefox-darwin.png | Bin 217936 -> 205268 bytes .../event-types-list-webkit-darwin.png | Bin 1299144 -> 1248578 bytes .../success-page-chromium-darwin.png | Bin 270941 -> 263592 bytes .../success-page-firefox-darwin.png | Bin 230070 -> 226224 bytes .../success-page-webkit-darwin.png | Bin 1281991 -> 1244336 bytes .../availability-page-1-chromium-darwin.png | Bin 272370 -> 265155 bytes .../availability-page-1-firefox-darwin.png | Bin 67176 -> 71163 bytes .../availability-page-1-webkit-darwin.png | Bin 1881854 -> 1884431 bytes .../availability-page-2-chromium-darwin.png | Bin 277432 -> 266646 bytes .../availability-page-2-firefox-darwin.png | Bin 161902 -> 161656 bytes .../availability-page-2-webkit-darwin.png | Bin 1890070 -> 1888157 bytes .../booking-page-chromium-darwin.png | Bin 294107 -> 288847 bytes .../booking-page-firefox-darwin.png | Bin 90870 -> 91688 bytes .../booking-page-webkit-darwin.png | Bin 1998450 -> 1995731 bytes .../event-types-list-chromium-darwin.png | Bin 293529 -> 268145 bytes .../event-types-list-firefox-darwin.png | Bin 67153 -> 66786 bytes .../event-types-list-webkit-darwin.png | Bin 2926043 -> 1893097 bytes .../success-page-chromium-darwin.png | Bin 337031 -> 361816 bytes .../success-page-firefox-darwin.png | Bin 155284 -> 155482 bytes .../success-page-webkit-darwin.png | Bin 2563086 -> 2562762 bytes packages/embeds/embed-core/preview.html | 80 ++ .../src/FloatingButton/FloatingButton.ts | 66 +- .../src/FloatingButton/FloatingButtonHtml.ts | 28 +- .../embeds/embed-core/src/Inline/inline.ts | 13 +- .../embed-core/src/Inline/inlineHtml.ts | 5 +- .../embed-core/src/ModalBox/ModalBox.ts | 8 +- .../embed-core/src/ModalBox/ModalBoxHtml.ts | 5 +- .../embeds/embed-core/src/embed-iframe.ts | 27 +- packages/embeds/embed-core/src/embed.ts | 76 +- packages/embeds/embed-core/src/preview.ts | 23 + packages/embeds/embed-core/src/utils.ts | 7 + packages/embeds/embed-core/vite.config.js | 3 + packages/embeds/embed-react/package.json | 2 +- packages/embeds/embed-snippet/src/index.ts | 4 +- packages/embeds/embed-snippet/vite.config.js | 4 + packages/ui/Dialog.tsx | 14 +- packages/ui/Switch.tsx | 2 +- 61 files changed, 1593 insertions(+), 153 deletions(-) create mode 100644 apps/docs/components/Anchor.tsx create mode 100644 apps/web/components/Embed.tsx create mode 100644 apps/web/playwright/embed-code-generator.test.ts create mode 100644 packages/embeds/embed-core/preview.html create mode 100644 packages/embeds/embed-core/src/preview.ts create mode 100644 packages/embeds/embed-core/src/utils.ts diff --git a/apps/docs/components/Anchor.tsx b/apps/docs/components/Anchor.tsx new file mode 100644 index 0000000000..b7322b6bf8 --- /dev/null +++ b/apps/docs/components/Anchor.tsx @@ -0,0 +1,21 @@ +function getAnchor(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9 ]/g, "") + .replace(/[ ]/g, "-") + .replace(/ /g, "%20"); +} + +export default function Anchor({ as, children }) { + const anchor = getAnchor(children); + const link = `#${anchor}`; + const Component = as || "div"; + return ( + + + § + + {children} + + ); +} diff --git a/apps/docs/pages/integrations/embed.mdx b/apps/docs/pages/integrations/embed.mdx index 40cde9d94c..7a876d8b45 100644 --- a/apps/docs/pages/integrations/embed.mdx +++ b/apps/docs/pages/integrations/embed.mdx @@ -2,13 +2,16 @@ title: Embed --- +import Anchor from "../../components/Anchor" + # Embed The Embed allows your website visitors to book a meeting with you directly from your website. ## Install on any website -- _Step-1._ Install the Vanilla JS Snippet +Install the following Vanilla JS Snippet to get embed to work on any website. After that you can choose any of the ways to show your Cal Link embedded on your website. + ```html -``` +*Sample sandbox* +``` + #### @@ -108,6 +118,14 @@ const MyComponent = () => ( ); ``` +*Sample sandbox* + + ### Popup on any existing element @@ -120,9 +138,16 @@ To show the embed as a popup on clicking an element, add `data-cal-link` attribu To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element. +*Sample sandbox* + + -
React ```jsx @@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link` const MyComponent = ()=> { return } +``` -```` +*Sample sandbox* +
+### Floating pop-up button + +```html + +``` + +*Sample sandbox* + + ## Supported Instructions Consider an instruction as a function with that name and that would be called with the given arguments. diff --git a/apps/web/components/Embed.tsx b/apps/web/components/Embed.tsx new file mode 100644 index 0000000000..31ab4a3991 --- /dev/null +++ b/apps/web/components/Embed.tsx @@ -0,0 +1,905 @@ +import { CodeIcon, EyeIcon, SunIcon, ChevronRightIcon, ArrowLeftIcon } from "@heroicons/react/solid"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; +import classNames from "classnames"; +import { useRouter } from "next/router"; +import { useRef, useState } from "react"; +import { components, ControlProps, SingleValue } from "react-select"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import { EventType } from "@calcom/prisma/client"; +import { Button, Switch } from "@calcom/ui"; +import { Dialog, DialogContent, DialogClose } from "@calcom/ui/Dialog"; +import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields"; + +import { trpc } from "@lib/trpc"; + +import NavTabs from "@components/NavTabs"; +import ColorPicker from "@components/ui/colorpicker"; +import Select from "@components/ui/form/Select"; + +type EmbedType = "inline" | "floating-popup" | "element-click"; +const queryParamsForDialog = ["embedType", "tabName", "eventTypeId"]; + +const embeds: { + illustration: React.ReactElement; + title: string; + subtitle: string; + type: EmbedType; +}[] = [ + { + title: "Inline Embed", + subtitle: "Loads your Cal scheduling page directly inline with your other website content", + type: "inline", + illustration: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, + { + title: "Floating pop-up button", + subtitle: "Adds a floating button on your site that launches Cal in a dialog.", + type: "floating-popup", + illustration: ( + + + + + + + + + + {/* */} + + ), + }, + { + title: "Pop up via element click", + subtitle: "Open your Cal dialog when someone clicks an element.", + type: "element-click", + illustration: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, +]; + +function getEmbedSnippetString() { + let embedJsUrl = "https://cal.com/embed.js"; + let isLocal = false; + if (location.hostname === "localhost") { + embedJsUrl = "http://localhost:3100/dist/embed.umd.js"; + isLocal = true; + } + // TODO: Import this string from @calcom/embed-snippet + return ` +(function (C, A, L) { let p = function (a, ar) { a.q.push(ar); }; let d = C.document; C.Cal = C.Cal || function () { let cal = C.Cal; let ar = arguments; if (!cal.loaded) { cal.ns = {}; cal.q = cal.q || []; d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (ar[0] === L) { const api = function () { p(api, arguments); }; const namespace = ar[1]; api.q = api.q || []; typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); }; })(window, "${embedJsUrl}", "init"); +Cal("init"${isLocal ? ', {origin:"http://localhost:3000/"}' : ""}); +`; +} + +const EmbedNavBar = () => { + const { t } = useLocale(); + const tabs = [ + { + name: t("Embed"), + tabName: "embed-code", + icon: CodeIcon, + }, + { + name: t("Preview"), + tabName: "embed-preview", + icon: EyeIcon, + }, + ]; + + return ; +}; +const ThemeSelectControl = ({ children, ...props }: ControlProps) => { + return ( + + + {children} + + ); +}; + +const ChooseEmbedTypesDialogContent = () => { + const { t } = useLocale(); + const router = useRouter(); + return ( + +
+ +
+

{t("choose_ways_put_cal_site")}

+
+
+
+ {embeds.map((embed, index) => ( + + ))} +
+
+ ); +}; + +const EmbedTypeCodeAndPreviewDialogContent = ({ + eventTypeId, + embedType, +}: { + eventTypeId: EventType["id"]; + embedType: EmbedType; +}) => { + const { t } = useLocale(); + const router = useRouter(); + const iframeRef = useRef(null); + const embedCode = useRef(null); + const embed = embeds.find((embed) => embed.type === embedType); + + const { data: eventType, isLoading } = trpc.useQuery([ + "viewer.eventTypes.get", + { + id: +eventTypeId, + }, + ]); + + const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true); + const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true); + const [previewState, setPreviewState] = useState({ + inline: { + width: "100%", + height: "100%", + }, + theme: "auto", + floatingPopup: {}, + elementClick: {}, + palette: { + brandColor: "#000000", + }, + }); + + const close = () => { + const noPopupQuery = { + ...router.query, + }; + + delete noPopupQuery.dialog; + + queryParamsForDialog.forEach((queryParam) => { + delete noPopupQuery[queryParam]; + }); + + router.push({ + query: noPopupQuery, + }); + }; + + // Use embed-code as default tab + if (!router.query.tabName) { + router.query.tabName = "embed-code"; + router.push({ + query: { + ...router.query, + }, + }); + } + + if (isLoading) { + return null; + } + + if (!embed || !eventType) { + close(); + return null; + } + + const calLink = `${eventType.team ? `team/${eventType.team.slug}` : eventType.users[0].username}/${ + eventType.slug + }`; + + // TODO: Not sure how to make these template strings look better formatted. + // This exact formatting is required to make the code look nicely formatted together. + const getEmbedUIInstructionString = () => + `Cal("ui", { + ${getThemeForSnippet() ? 'theme: "' + previewState.theme + '",\n ' : ""}styles: { + branding: ${JSON.stringify(previewState.palette)} + } +})`; + + const getEmbedTypeSpecificString = () => { + if (embedType === "inline") { + return ` +Cal("inline", { + elementOrSelector:"#my-cal-inline", + calLink: "${calLink}" +}); +${getEmbedUIInstructionString().trim()}`; + } else if (embedType === "floating-popup") { + let floatingButtonArg = { + calLink, + ...previewState.floatingPopup, + }; + return ` +Cal("floatingButton", ${JSON.stringify(floatingButtonArg)}); +${getEmbedUIInstructionString().trim()}`; + } else if (embedType === "element-click") { + return `//Important: Also, add data-cal-link="${calLink}" attribute to the element you want to open Cal on click +${getEmbedUIInstructionString().trim()}`; + } + return ""; + }; + + const getThemeForSnippet = () => { + return previewState.theme !== "auto" ? previewState.theme : null; + }; + + const getDimension = (dimension: string) => { + if (dimension.match(/^\d+$/)) { + dimension = `${dimension}%`; + } + return dimension; + }; + + const addToPalette = (update: typeof previewState["palette"]) => { + setPreviewState((previewState) => { + return { + ...previewState, + palette: { + ...previewState.palette, + ...update, + }, + }; + }); + }; + + const previewInstruction = (instruction: { name: string; arg: any }) => { + iframeRef.current?.contentWindow?.postMessage( + { + mode: "cal:preview", + type: "instruction", + instruction, + }, + "*" + ); + }; + + const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => { + iframeRef.current?.contentWindow?.postMessage( + { + mode: "cal:preview", + type: "inlineEmbedDimensionUpdate", + data: { + width: getDimension(width), + height: getDimension(height), + }, + }, + "*" + ); + }; + + previewInstruction({ + name: "ui", + arg: { + theme: previewState.theme, + styles: { + branding: { + ...previewState.palette, + }, + }, + }, + }); + + if (embedType === "floating-popup") { + previewInstruction({ + name: "floatingButton", + arg: { + attributes: { + id: "my-floating-button", + }, + ...previewState.floatingPopup, + }, + }); + } + + if (embedType === "inline") { + inlineEmbedDimensionUpdate({ + width: previewState.inline.width, + height: previewState.inline.height, + }); + } + + const ThemeOptions = [ + { value: "auto", label: "Auto Theme" }, + { value: "dark", label: "Dark Theme" }, + { value: "light", label: "Light Theme" }, + ]; + + const FloatingPopupPositionOptions = [ + { + value: "bottom-right", + label: "Bottom Right", + }, + { + value: "bottom-left", + label: "Bottom Left", + }, + ]; + + return ( + +
+
+ +
+
+ setIsEmbedCustomizationOpen((val) => !val)}> + +
+ {embedType === "inline" + ? "Inline Embed Customization" + : embedType === "floating-popup" + ? "Floating Popup Customization" + : "Element Click Customization"} +
+ +
+ +
+ {/*TODO: Add Auto/Fixed toggle from Figma */} +
Embed Window Sizing
+
+ { + setPreviewState((previewState) => { + let width = e.target.value || "100%"; + + return { + ...previewState, + inline: { + ...previewState.inline, + width, + }, + }; + }); + }} + addOnLeading={W} + /> + x + { + const height = e.target.value || "100%"; + + setPreviewState((previewState) => { + return { + ...previewState, + inline: { + ...previewState.inline, + height, + }, + }; + }); + }} + addOnLeading={H} + /> +
+
+
+
Button Text
+ {/* Default Values should come from preview iframe */} + { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonText: e.target.value, + }, + }; + }); + }} + defaultValue="Book my Cal" + required + /> +
+
+
Display Calendar Icon Button
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + hideButtonIcon: !checked, + }, + }; + }); + }}> +
+
+
Position of Button
+ +
+
+
Button Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonColor: color, + }, + }; + }); + }}> +
+
+
+
Text Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonTextColor: color, + }, + }; + }); + }}> +
+
+ {/*
+
Button Color on Hover
+
+ { + addToPalette({ + "floating-popup-button-color-hover": color, + }); + }}> +
+
*/} +
+
+
+
+
+ setIsBookingCustomizationOpen((val) => !val)}> + +
Cal Booking Customization
+ +
+ +
+ + {[ + { name: "brandColor", title: "Brand Color" }, + // { name: "lightColor", title: "Light Color" }, + // { name: "lighterColor", title: "Lighter Color" }, + // { name: "lightestColor", title: "Lightest Color" }, + // { name: "highlightColor", title: "Highlight Color" }, + // { name: "medianColor", title: "Median Color" }, + ].map((palette) => ( + + ))} +
+
+
+
+
+
+ +
+
+ {t("place_where_cal_widget_appear")} + +

+ {t( + "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options." + )} +

+
+
+ `; - const htmlTemplate = `${t( - "schedule_a_meeting" - )}${iframeTemplate}`; - - return ( - <> - -
- - -
- Embed -
- {t("standard_iframe")} - {t("embed_your_calendar")} -
-
- - -
-
-
- -
- Embed -
- {t("responsive_fullscreen_iframe")} - A fullscreen scheduling experience on your website -
-
- - -
-
-
-
-
-
- -
-
-
- -
-
-
-
- - ); -} - function ConnectOrDisconnectIntegrationButton(props: { // credentialIds: number[]; @@ -342,7 +261,6 @@ export default function IntegrationsPage() { - diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index d02b82a991..398d90d99f 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -52,6 +52,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps"; import { ClientSuspense } from "@components/ClientSuspense"; import DestinationCalendarSelector from "@components/DestinationCalendarSelector"; +import { EmbedButton, EmbedDialog } from "@components/Embed"; import Loader from "@components/Loader"; import Shell from "@components/Shell"; import { Tooltip } from "@components/Tooltip"; @@ -1822,6 +1823,10 @@ const EventTypePage = (props: inferSSRProps) => { {t("copy_link")} + @@ -1969,6 +1974,7 @@ const EventTypePage = (props: inferSSRProps) => { /> )} +
); diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 2550cb202f..429a81316d 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -10,13 +10,14 @@ import { ClipboardCopyIcon, TrashIcon, PencilIcon, + CodeIcon, } from "@heroicons/react/solid"; import { UsersIcon } from "@heroicons/react/solid"; import { Trans } from "next-i18next"; import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; -import React, { Fragment, useEffect, useState } from "react"; +import React, { Fragment, useEffect, useRef, useState } from "react"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -36,6 +37,7 @@ import classNames from "@lib/classNames"; import { HttpError } from "@lib/core/http/error"; import { inferQueryOutput, trpc } from "@lib/trpc"; +import { EmbedButton, EmbedDialog } from "@components/Embed"; import EmptyScreen from "@components/EmptyScreen"; import Shell from "@components/Shell"; import { Tooltip } from "@components/Tooltip"; @@ -299,6 +301,12 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL {t("duplicate")} + + + @@ -519,9 +527,9 @@ const CTA = () => { }; const WithQuery = withQuery(["viewer.eventTypes"]); + const EventTypesPage = () => { const { t } = useLocale(); - return (
@@ -574,6 +582,7 @@ const EventTypesPage = () => { {data.eventTypeGroups.length === 0 && ( )} + )} /> diff --git a/apps/web/playwright/embed-code-generator.test.ts b/apps/web/playwright/embed-code-generator.test.ts new file mode 100644 index 0000000000..8f8e442c49 --- /dev/null +++ b/apps/web/playwright/embed-code-generator.test.ts @@ -0,0 +1,188 @@ +import { expect, Page, test } from "@playwright/test"; + +function chooseEmbedType(page: Page, embedType: string) { + page.locator(`[data-testid=${embedType}]`).click(); +} + +async function gotToPreviewTab(page: Page) { + await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click(); +} + +async function clickEmbedButton(page: Page) { + const embedButton = page.locator("[data-testid=event-type-embed]"); + const eventTypeId = await embedButton.getAttribute("data-test-eventtype-id"); + embedButton.click(); + return eventTypeId; +} + +async function clickFirstEventTypeEmbedButton(page: Page) { + const menu = page.locator("[data-testid*=event-type-options]").first(); + await menu.click(); + const eventTypeId = await clickEmbedButton(page); + return eventTypeId; +} + +async function expectToBeNavigatingToEmbedTypesDialog( + page: Page, + { eventTypeId, basePage }: { eventTypeId: string | null; basePage: string } +) { + if (!eventTypeId) { + throw new Error("Couldn't find eventTypeId"); + } + await page.waitForNavigation({ + url: (url) => { + return ( + url.pathname === basePage && + url.searchParams.get("dialog") === "embed" && + url.searchParams.get("eventTypeId") === eventTypeId + ); + }, + }); +} + +async function expectToBeNavigatingToEmbedCodeAndPreviewDialog( + page: Page, + { eventTypeId, embedType, basePage }: { eventTypeId: string | null; embedType: string; basePage: string } +) { + if (!eventTypeId) { + throw new Error("Couldn't find eventTypeId"); + } + await page.waitForNavigation({ + url: (url) => { + return ( + url.pathname === basePage && + url.searchParams.get("dialog") === "embed" && + url.searchParams.get("eventTypeId") === eventTypeId && + url.searchParams.get("embedType") === embedType && + url.searchParams.get("tabName") === "embed-code" + ); + }, + }); +} + +async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) { + const embedCode = await page.locator("[data-testid=embed-code]").inputValue(); + expect(embedCode.includes("(function (C, A, L)")).toBe(true); + expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true); + return { + message: () => `passed`, + pass: true, + }; +} + +async function expectToContainValidPreviewIframe( + page: Page, + { embedType, calLink }: { embedType: string; calLink: string } +) { + expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain( + `/preview.html?embedType=${embedType}&calLink=${calLink}` + ); +} + +test.describe("Embed Code Generator Tests", () => { + test.use({ storageState: "playwright/artifacts/proStorageState.json" }); + + test.describe("Event Types Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types"); + }); + + test("open Embed Dialog and choose Inline for First Event Type", async ({ page }) => { + const eventTypeId = await clickFirstEventTypeEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + eventTypeId, + basePage: "/event-types", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + eventTypeId, + embedType: "inline", + basePage: "/event-types", + }); + + await expectToContainValidCode(page, { embedType: "inline" }); + + await gotToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { embedType: "inline", calLink: "pro/30min" }); + }); + + test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page }) => { + const eventTypeId = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + eventTypeId, + basePage: "/event-types", + }); + + chooseEmbedType(page, "floating-popup"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + eventTypeId, + embedType: "floating-popup", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { embedType: "floating-popup" }); + + await gotToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { embedType: "floating-popup", calLink: "pro/30min" }); + }); + + test("open Embed Dialog and choose element-click for First Event Type", async ({ page }) => { + const eventTypeId = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + eventTypeId, + basePage: "/event-types", + }); + + chooseEmbedType(page, "element-click"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + eventTypeId, + embedType: "element-click", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { embedType: "element-click" }); + + await gotToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { embedType: "element-click", calLink: "pro/30min" }); + }); + }); + + test.describe("Event Type Edit Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types/3"); + }); + + test("open Embed Dialog for the Event Type", async ({ page }) => { + const eventTypeId = await clickEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + eventTypeId, + basePage: "/event-types/3", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + eventTypeId, + basePage: "/event-types/3", + embedType: "inline", + }); + + await expectToContainValidCode(page, { + embedType: "inline", + }); + + gotToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: "pro/30min", + }); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 470d7425e9..02806885c6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -774,6 +774,11 @@ "impersonate_user_tip":"All uses of this feature is audited.", "impersonating_user_warning":"Impersonating username \"{{user}}\".", "impersonating_stop_instructions": "<0>Click Here to stop.", + "place_where_cal_widget_appear": "Place this code in your HTML where you want your Cal widget to appear.", + "copy_code": "Copy Code", + "code_copied": "Code copied!", + "how_you_want_add_cal_site":"How do you want to add Cal to your site?", + "choose_ways_put_cal_site":"Choose one of the following ways to put Cal on your site.", "setting_up_zapier": "Setting up your Zapier integration", "generate_api_key": "Generate Api Key", "your_unique_api_key": "Your unique API key", diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx index bbc2822a4b..738e68eafa 100644 --- a/apps/web/server/routers/viewer/eventTypes.tsx +++ b/apps/web/server/routers/viewer/eventTypes.tsx @@ -211,6 +211,40 @@ export const eventTypesRouter = createProtectedRouter() return next(); }) + .query("get", { + input: z.object({ + id: z.number(), + }), + async resolve({ ctx, input }) { + const user = await ctx.prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + avatar: true, + plan: true, + }, + }); + if (!user) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + return await ctx.prisma.eventType.findUnique({ + where: { + id: input.id, + }, + include: { + team: true, + users: true, + }, + }); + }, + }) .mutation("update", { input: EventTypeUpdateInput.strict(), async resolve({ ctx, input }) { diff --git a/packages/embeds/embed-core/README.md b/packages/embeds/embed-core/README.md index 23795f3bcb..9b68c9a9e3 100644 --- a/packages/embeds/embed-core/README.md +++ b/packages/embeds/embed-core/README.md @@ -56,6 +56,7 @@ Make `dist/embed.umd.js` servable on URL - Automation Tests - Run automation tests in CI + - Automation Tests are using snapshots of Booking Page which has current month which requires us to regenerate snapshots every month. - Bundling Related - Comments in CSS aren't stripped off @@ -72,13 +73,8 @@ Make `dist/embed.umd.js` servable on URL - Dev Experience/Ease of Installation - Do we need a one liner(like `window.dataLayer.push`) to inform SDK of something even if snippet is not yet on the page but would be there e.g. through GTM it would come late on the page ? -- Might be better to pass all configuration using a single base64encoded query param to booking page. - -- Performance Improvements - - Custom written Tailwind CSS is sent multiple times for different custom elements. - -- Embed Code Generator - Option to disable redirect banner and let parent handle redirect. + - Release Issues - Compatibility Issue - When embed-iframe.js is updated in such a way that it is not compatible with embed.js, doing a release might break the embed for some time. e.g. iframeReady event let's say get's changed to something else - Best Case scenario - App and Website goes live at the same time. A website using embed loads the same updated and thus compatible versions of embed.js and embed-iframe.js @@ -87,8 +83,7 @@ Make `dist/embed.umd.js` servable on URL - Quick Solution: Serve embed.js also from app, so that they go live together and there is only a slight chance of compatibility issues on going live. Note, that they can still occur as 2 different requests are sent at different times to fetch the libraries and deployments can go live in between, - UI Config Features - - Theme switch dynamically - If user switches the theme on website, he should be able to do it on embed. Add a demo for the API. Also, test system theme handling. - - How would the user add on hover styles just using style attribute ? + - How would the user add on hover styles just using style attribute ? - If just iframe refreshes due to some reason, embed script can't replay the applied instructions. diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index cf7842daea..016f88a672 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -1,6 +1,7 @@ + + + + + + +
+ + + diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts index 01d3e25b6d..fbba84597d 100644 --- a/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts +++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButton.ts @@ -1,11 +1,73 @@ import { CalWindow } from "@calcom/embed-snippet"; -import floatingButtonHtml from "./FloatingButtonHtml"; +import getFloatingButtonHtml from "./FloatingButtonHtml"; export class FloatingButton extends HTMLElement { + static updatedClassString(position: string, classString: string) { + return [ + classString.replace(/hidden|md:right-10|md:left-10|left-4|right-4/g, ""), + position === "bottom-right" ? "md:right-10 right-4" : "md:left-10 left-4", + ].join(" "); + } + + //@ts-ignore + static get observedAttributes() { + return [ + "data-button-text", + "data-hide-button-icon", + "data-button-position", + "data-button-color", + "data-button-text-color", + ]; + } + + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + if (name === "data-button-text") { + const buttonEl = this.shadowRoot?.querySelector("#button"); + if (!buttonEl) { + throw new Error("Button not found"); + } + buttonEl.innerHTML = newValue; + } else if (name === "data-hide-button-icon") { + const buttonIconEl = this.shadowRoot?.querySelector("#button-icon") as HTMLElement; + if (!buttonIconEl) { + throw new Error("Button not found"); + } + buttonIconEl.style.display = newValue == "true" ? "none" : "block"; + } else if (name === "data-button-position") { + const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement; + if (!buttonEl) { + throw new Error("Button not found"); + } + buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className); + } else if (name === "data-button-color") { + const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement; + if (!buttonEl) { + throw new Error("Button not found"); + } + buttonEl.style.backgroundColor = newValue; + } else if (name === "data-button-text-color") { + const buttonEl = this.shadowRoot?.querySelector("button") as HTMLElement; + if (!buttonEl) { + throw new Error("Button not found"); + } + buttonEl.style.color = newValue; + } + } constructor() { super(); - const buttonHtml = ` ${floatingButtonHtml}`; + const buttonText = this.dataset["buttonText"]; + const buttonPosition = this.dataset["buttonPosition"]; + const buttonColor = this.dataset["buttonColor"]; + const buttonTextColor = this.dataset["buttonTextColor"]; + + //TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place + const buttonHtml = ` ${getFloatingButtonHtml({ + buttonText: buttonText!, + buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")], + buttonColor: buttonColor!, + buttonTextColor: buttonTextColor!, + })}`; this.attachShadow({ mode: "open" }); this.shadowRoot!.innerHTML = buttonHtml; } diff --git a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts index 792032d879..e015f9c655 100644 --- a/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts +++ b/packages/embeds/embed-core/src/FloatingButton/FloatingButtonHtml.ts @@ -1,8 +1,23 @@ -const html = ``; +}; -export default html; +export default getHtml; diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index e17e929115..f0e101e691 100644 --- a/packages/embeds/embed-core/src/Inline/inline.ts +++ b/packages/embeds/embed-core/src/Inline/inline.ts @@ -1,6 +1,7 @@ import { CalWindow } from "@calcom/embed-snippet"; import loaderCss from "../loader.css"; +import { getErrorString } from "../utils"; import inlineHtml from "./inlineHtml"; export class Inline extends HTMLElement { @@ -9,8 +10,16 @@ export class Inline extends HTMLElement { return ["loading"]; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { - if (name === "loading" && newValue == "done") { - (this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none"; + if (name === "loading") { + if (newValue == "done") { + (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none"; + } else if (newValue === "failed") { + (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none"; + (this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "block"; + (this.shadowRoot!.querySelector("slot")! as HTMLElement).style.visibility = "hidden"; + const errorString = getErrorString(this.dataset.errorCode); + (this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString; + } } } constructor() { diff --git a/packages/embeds/embed-core/src/Inline/inlineHtml.ts b/packages/embeds/embed-core/src/Inline/inlineHtml.ts index 80a87b2afa..44c4c7e372 100644 --- a/packages/embeds/embed-core/src/Inline/inlineHtml.ts +++ b/packages/embeds/embed-core/src/Inline/inlineHtml.ts @@ -1,7 +1,10 @@ -const html = `
+const html = `
+
`; export default html; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index a6e6a2b2df..9a7b4dfa08 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -1,6 +1,7 @@ import { CalWindow } from "@calcom/embed-snippet"; import loaderCss from "../loader.css"; +import { getErrorString } from "../utils"; import modalBoxHtml from "./ModalBoxHtml"; export class ModalBox extends HTMLElement { @@ -28,11 +29,16 @@ export class ModalBox extends HTMLElement { } if (newValue == "loaded") { - (this.shadowRoot!.querySelector("#loader")! as HTMLElement).style.display = "none"; + (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none"; } else if (newValue === "started") { this.show(true); } else if (newValue == "closed") { this.show(false); + } else if (newValue === "failed") { + (this.shadowRoot!.querySelector(".loader")! as HTMLElement).style.display = "none"; + (this.shadowRoot!.querySelector("#error")! as HTMLElement).style.display = "inline-block"; + const errorString = getErrorString(this.dataset.errorCode); + (this.shadowRoot!.querySelector("#error")! as HTMLElement).innerText = errorString; } } diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts index 6222e91abf..9e2178cd2f 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts @@ -59,11 +59,12 @@ const html = ` - - -
- diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index b09b58b92a..04b328fdbc 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -7,6 +7,16 @@ export interface UiConfig { theme?: "dark" | "light" | "auto"; styles?: EmbedStyles; } +declare global { + interface Window { + CalEmbed: { + __logQueue?: any[]; + embedStore: any; + }; + CalComPageStatus: string; + CalComPlan: string; + } +} const embedStore = { // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. @@ -37,6 +47,9 @@ if (isBrowser) { if (isSafariBrowser) { log("Safari Detected: Using setTimeout instead of rAF"); } + window.CalEmbed = window.CalEmbed || {}; + //TODO: Send postMessage to parent to get all log messages in the same queue. + window.CalEmbed.embedStore = embedStore; } function runAsap(fn: (...arg: any) => void) { @@ -47,23 +60,11 @@ function runAsap(fn: (...arg: any) => void) { return requestAnimationFrame(fn); } -declare global { - interface Window { - CalEmbed: { - __logQueue?: any[]; - }; - CalComPageStatus: string; - CalComPlan: string; - } -} - function log(...args: any[]) { if (isBrowser) { const namespace = getNamespace(); const searchParams = new URL(document.URL).searchParams; - //TODO: Send postMessage to parent to get all log messages in the same queue. - window.CalEmbed = window.CalEmbed || {}; const logQueue = (window.CalEmbed.__logQueue = window.CalEmbed.__logQueue || []); args.push({ ns: namespace, diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index af1564fe2b..0b945cea51 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -8,6 +8,12 @@ import css from "./embed.css"; import { SdkActionManager } from "./sdk-action-manager"; import allCss from "./tailwind.generated.css"; +// HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file. +// This is happening because we are not able to generate an App and a lib using single Vite Config. +const WEBAPP_URL = + (import.meta.env.NEXT_PUBLIC_WEBAPP_URL_TYPO as string) || + `https://${import.meta.env.NEXT_PUBLIC_VERCEL_URL}`; + customElements.define("cal-modal-box", ModalBox); customElements.define("cal-floating-button", FloatingButton); customElements.define("cal-inline", Inline); @@ -414,8 +420,8 @@ export class Cal { constructor(namespace: string, q: InstructionQueue) { this.__config = { - // Keep cal.com hardcoded till the time embed.js deployment to cal.com/embed.js is automated. This is to prevent accidentally pushing of localhost domain to production - origin: /*import.meta.env.NEXT_PUBLIC_WEBSITE_URL || */ "https://app.cal.com", + // Use WEBAPP_URL till full page reload problem with website URL is solved + origin: WEBAPP_URL, }; this.namespace = namespace; this.actionManager = new SdkActionManager(namespace); @@ -507,7 +513,11 @@ window.addEventListener("message", (e) => { if (!parsedAction) { return; } + const actionManager = Cal.actionsManagers[parsedAction.ns]; + globalCal.__logQueue = globalCal.__logQueue || []; + globalCal.__logQueue.push({ ...parsedAction, data: detail.data }); + if (!actionManager) { throw new Error("Unhandled Action" + parsedAction); } diff --git a/packages/embeds/embed-core/src/preview.ts b/packages/embeds/embed-core/src/preview.ts index fcbdbeee8f..9d671ca2ce 100644 --- a/packages/embeds/embed-core/src/preview.ts +++ b/packages/embeds/embed-core/src/preview.ts @@ -1,6 +1,76 @@ import { CalWindow } from "@calcom/embed-snippet"; -window.addEventListener("message", (e) => { +const WEBAPP_URL = + import.meta.env.NEXT_PUBLIC_WEBAPP_URL || `https://${import.meta.env.NEXT_PUBLIC_VERCEL_URL}`; +const EMBED_LIB_URL = import.meta.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`; + +(window as any).fingerprint = import.meta.env.NEXT_PUBLIC_EMBED_FINGER_PRINT as string; + +// Install Cal Embed Code Snippet +(function (C, A, L) { + // @ts-ignore + let p = function (a, ar) { + a.q.push(ar); + }; + let d = C.document; + // @ts-ignore + C.Cal = + // @ts-ignore + C.Cal || + function () { + // @ts-ignore + let cal = C.Cal; + let ar = arguments; + if (!cal.loaded) { + cal.ns = {}; + cal.q = cal.q || []; + // @ts-ignore + d.head.appendChild(d.createElement("script")).src = A; + cal.loaded = true; + } + if (ar[0] === L) { + const api = function () { + p(api, arguments); + }; + const namespace = ar[1]; + // @ts-ignore + api.q = api.q || []; + // @ts-ignore + typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); + return; + } + p(cal, ar); + }; +})(window, EMBED_LIB_URL, "init"); + +const previewWindow: CalWindow = window; + +previewWindow.Cal!("init", { + origin: WEBAPP_URL, +}); +const searchParams = new URL(document.URL).searchParams; +const embedType = searchParams.get("embedType"); +const calLink = searchParams.get("calLink"); +if (embedType! === "inline") { + previewWindow.Cal!("inline", { + elementOrSelector: "#my-embed", + calLink: calLink, + }); +} else if (embedType === "floating-popup") { + previewWindow.Cal!("floatingButton", { + calLink: calLink, + attributes: { + id: "my-floating-button", + }, + }); +} else if (embedType === "element-click") { + const button = document.createElement("button"); + button.setAttribute("data-cal-link", calLink!); + button.innerHTML = "I am a button that exists on your website"; + document.body.appendChild(button); +} + +previewWindow.addEventListener("message", (e) => { const data = e.data; if (data.mode !== "cal:preview") { return; diff --git a/packages/embeds/embed-core/vite.config.js b/packages/embeds/embed-core/vite.config.js index b6a5563ff5..cfe4c9cd1e 100644 --- a/packages/embeds/embed-core/vite.config.js +++ b/packages/embeds/embed-core/vite.config.js @@ -1,23 +1,39 @@ require("dotenv").config({ path: "../../../.env" }); +process.env.NEXT_PUBLIC_VERCEL_URL = process.env.VERCEL_URL; + const path = require("path"); const { defineConfig } = require("vite"); -module.exports = defineConfig({ - envPrefix: "NEXT_PUBLIC_", - build: { - minify: "terser", - watch: { - include: ["src/**"], - }, - terserOptions: { - format: { - comments: false, +module.exports = defineConfig((configEnv) => { + const config = { + envPrefix: "NEXT_PUBLIC_", + base: "/embed/", + build: { + minify: "terser", + terserOptions: { + format: { + comments: false, + }, + }, + rollupOptions: { + input: { + preview: path.resolve(__dirname, "preview.html"), + embed: path.resolve(__dirname, "src/embed.ts"), + }, + output: { + entryFileNames: "[name].js", + //FIXME: Can't specify UMD as import because preview is an app which doesn't support `format` and this setting apply to both input + //format: "umd", + dir: "../../../apps/web/public/embed", + }, }, }, - lib: { - entry: path.resolve(__dirname, "src/embed.ts"), - name: "embed", - fileName: (format) => `embed.${format}.js`, - }, - }, + }; + + if (configEnv.mode === "development") { + config.build.watch = { + include: ["src/**"], + }; + } + return config; }); diff --git a/packages/embeds/embed-react/playwright/tests/basic.test.ts-snapshots/react-component-inline-chromium-darwin.png b/packages/embeds/embed-react/playwright/tests/basic.test.ts-snapshots/react-component-inline-chromium-darwin.png index 249377ff611793c5bf74fbc559248c655df04bae..d17880cf339df1b364c44910233291885fa376eb 100644 GIT binary patch literal 35442 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 literal 58166 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 Date: Fri, 6 May 2022 12:21:30 -0500 Subject: [PATCH 32/55] Vital App - Auto reschedule based on health data (#2500) * Add vital integration * Tidy up client_user_id creation * Rename vital app to vitalother to follow name rules * Added env var * App vital reschedule * Fix on app structure and api calls * Implemented user identification from webhook * WIP fix api call and read me * Save vital settings via api * Now saving userVitalSettings and trigger reschedule on selected param * Added translations * Fix type for vitalSettings * Using api to get env vars required for url, fix display of vital settings * Fix hours placeholder, translation not working * Renames vital app * Update seed-app-store.ts * Update package.json * Update yarn.lock * Refactored env variables * Update README.md * Migrates to api_keys * Extracts AppConfiguration * vitalClient fixes * Update index.ts * Update metadata.ts * Update index.ts * Update metadata.ts * Added namespace vital for translations Co-authored-by: Maitham Co-authored-by: zomars Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .env.appStore.example | 10 + README.md | 19 +- apps/web/next-i18next.config.js | 5 +- apps/web/pages/apps/installed.tsx | 7 +- apps/web/public/static/locales/en/vital.json | 13 + apps/web/public/static/locales/es/vital.json | 13 + apps/web/server/createContext.ts | 2 +- .../_components/AppConfiguration.tsx | 19 + packages/app-store/apiHandlers.tsx | 1 + packages/app-store/components.tsx | 3 + packages/app-store/index.ts | 2 + packages/app-store/metadata.ts | 2 + packages/app-store/vital/README.mdx | 6 + packages/app-store/vital/_metadata.ts | 27 + packages/app-store/vital/api/callback.ts | 41 ++ packages/app-store/vital/api/index.ts | 5 + packages/app-store/vital/api/save.ts | 91 ++++ packages/app-store/vital/api/settings.ts | 29 ++ .../vital/api/sleep.create.payload.example | 23 + packages/app-store/vital/api/token.ts | 57 +++ packages/app-store/vital/api/webhook.ts | 158 ++++++ .../vital/components/AppConfiguration.tsx | 177 +++++++ .../vital/components/InstallAppButton.tsx | 39 ++ packages/app-store/vital/components/index.ts | 2 + packages/app-store/vital/index.ts | 4 + packages/app-store/vital/lib/client.ts | 34 ++ packages/app-store/vital/lib/emailManager.ts | 35 ++ .../app-store/vital/lib/emailServerConfig.ts | 34 ++ packages/app-store/vital/lib/index.ts | 1 + packages/app-store/vital/lib/reschedule.ts | 170 +++++++ .../attendee-request-reschedule-email.ts | 208 ++++++++ .../vital/lib/templates/base-template.ts | 409 +++++++++++++++ .../vital/lib/templates/common/body-logo.ts | 44 ++ .../vital/lib/templates/common/head.ts | 91 ++++ .../vital/lib/templates/common/index.ts | 6 + .../vital/lib/templates/common/link-icon.ts | 5 + .../common/scheduling-body-divider.ts | 31 ++ .../common/scheduling-body-head-content.ts | 33 ++ .../templates/common/scheduling-body-head.ts | 71 +++ .../organizer-request-reschedule-email.ts | 188 +++++++ packages/app-store/vital/package.json | 16 + packages/app-store/vital/static/icon.svg | 4 + packages/lib/hooks/useLocale.ts | 4 +- packages/prisma/seed-app-store.ts | 9 + packages/ui/form/Select.tsx | 63 +++ packages/ui/index.tsx | 3 +- yarn.lock | 471 +++++++++++++++++- 47 files changed, 2650 insertions(+), 35 deletions(-) create mode 100644 apps/web/public/static/locales/en/vital.json create mode 100644 apps/web/public/static/locales/es/vital.json create mode 100644 packages/app-store/_components/AppConfiguration.tsx create mode 100644 packages/app-store/vital/README.mdx create mode 100644 packages/app-store/vital/_metadata.ts create mode 100644 packages/app-store/vital/api/callback.ts create mode 100644 packages/app-store/vital/api/index.ts create mode 100644 packages/app-store/vital/api/save.ts create mode 100644 packages/app-store/vital/api/settings.ts create mode 100644 packages/app-store/vital/api/sleep.create.payload.example create mode 100644 packages/app-store/vital/api/token.ts create mode 100644 packages/app-store/vital/api/webhook.ts create mode 100644 packages/app-store/vital/components/AppConfiguration.tsx create mode 100644 packages/app-store/vital/components/InstallAppButton.tsx create mode 100644 packages/app-store/vital/components/index.ts create mode 100644 packages/app-store/vital/index.ts create mode 100644 packages/app-store/vital/lib/client.ts create mode 100644 packages/app-store/vital/lib/emailManager.ts create mode 100644 packages/app-store/vital/lib/emailServerConfig.ts create mode 100644 packages/app-store/vital/lib/index.ts create mode 100644 packages/app-store/vital/lib/reschedule.ts create mode 100644 packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts create mode 100644 packages/app-store/vital/lib/templates/base-template.ts create mode 100644 packages/app-store/vital/lib/templates/common/body-logo.ts create mode 100644 packages/app-store/vital/lib/templates/common/head.ts create mode 100644 packages/app-store/vital/lib/templates/common/index.ts create mode 100644 packages/app-store/vital/lib/templates/common/link-icon.ts create mode 100644 packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts create mode 100644 packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts create mode 100644 packages/app-store/vital/lib/templates/common/scheduling-body-head.ts create mode 100644 packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts create mode 100644 packages/app-store/vital/package.json create mode 100644 packages/app-store/vital/static/icon.svg create mode 100644 packages/ui/form/Select.tsx diff --git a/.env.appStore.example b/.env.appStore.example index d18d160153..9f6825c32f 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -71,4 +71,14 @@ ZOOM_CLIENT_SECRET= # Used for the Giphy integration # @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key GIPHY_API_KEY= + +# - VITAL +# Used for the vital integration +# @see https://github.com/calcom/cal.com/#obtaining-vital-api-keys +VITAL_API_KEY= +VITAL_WEBHOOK_SECRET= +# "sandbox" | "prod" | "production" | "development" +VITAL_DEVELOPMENT_MODE="sandbox" +# "us" | "eu" +VITAL_REGION="us" # ********************************************************************************************************* diff --git a/README.md b/README.md index 931c499e9e..cb25ed5ec5 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Here is what you need to be able to run Cal. ```sh yarn ``` - + 1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file. #### Quick start with `yarn dx` @@ -232,9 +232,9 @@ yarn workspace @calcom/web playwright-report 1. Check for `.env` variables changes - ```sh - yarn predev - ``` + ```sh + yarn predev + ``` 1. Start the server. In a development environment, just do: @@ -403,6 +403,17 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type 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 Vital API Keys + +1. Open [Vital](https://tryvital.io/) and click Get API Keys. +1. Create a team with the team name you desire +1. Head to the configuration section on the sidebar of the dashboard +1. Click on API keys and you'll find your sandbox `api_key`. +1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file. +1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `/api/integrations/vital/webhook` as webhook for connected applications. +1. Select all events for the webhook you interested, e.g. `sleep_created` +1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file. + ## License diff --git a/apps/web/next-i18next.config.js b/apps/web/next-i18next.config.js index 336d25a896..923695bde3 100644 --- a/apps/web/next-i18next.config.js +++ b/apps/web/next-i18next.config.js @@ -1,6 +1,7 @@ const path = require("path"); -module.exports = { +/** @type {import("next-i18next").UserConfig} */ +const config = { i18n: { defaultLocale: "en", locales: [ @@ -31,3 +32,5 @@ module.exports = { localePath: path.resolve("./public/static/locales"), reloadOnPrerender: process.env.NODE_ENV !== "production", }; + +module.exports = config; diff --git a/apps/web/pages/apps/installed.tsx b/apps/web/pages/apps/installed.tsx index 558b821a1a..5a1cbb299c 100644 --- a/apps/web/pages/apps/installed.tsx +++ b/apps/web/pages/apps/installed.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import React, { useEffect, useState } from "react"; import { JSONObject } from "superjson/dist/types"; -import { InstallAppButton } from "@calcom/app-store/components"; +import { AppConfiguration, InstallAppButton } from "@calcom/app-store/components"; import showToast from "@calcom/lib/notification"; import { App } from "@calcom/types/App"; import { Alert } from "@calcom/ui/Alert"; @@ -161,8 +161,9 @@ function IntegrationsContainer() { isGlobal={item.isGlobal} installed={item.installed} /> - } - /> + }> + + ))} diff --git a/apps/web/public/static/locales/en/vital.json b/apps/web/public/static/locales/en/vital.json new file mode 100644 index 0000000000..a08a9058b2 --- /dev/null +++ b/apps/web/public/static/locales/en/vital.json @@ -0,0 +1,13 @@ +{ + "connected_vital_app": "Connected with", + "vital_app_sleep_automation": "Sleeping reschedule automation", + "vital_app_automation_description": "You can select different parameters to trigger the reschedule based on your sleeping metrics.", + "vital_app_parameter": "Parameter", + "vital_app_trigger": "Trigger at below or equal than", + "vital_app_save_button": "Save configuration", + "vital_app_total_label": "Total (total = rem + light sleep + deep sleep)", + "vital_app_duration_label": "Duration (duration = bedtime end - bedtime start)", + "vital_app_hours": "hours", + "vital_app_save_success": "Success saving your Vital Configurations", + "vital_app_save_error": "An error ocurred saving your Vital Configurations" +} diff --git a/apps/web/public/static/locales/es/vital.json b/apps/web/public/static/locales/es/vital.json new file mode 100644 index 0000000000..d571e02e7c --- /dev/null +++ b/apps/web/public/static/locales/es/vital.json @@ -0,0 +1,13 @@ +{ + "connected_vital_app": "Conectado con", + "vital_app_sleep_automation": "Automatización de reagendado en base al patron de sueño", + "vital_app_automation_description": "Puedes seleccionar diferentes parámetros para activar el reagendado automático en base a tus patrones de sueño.", + "vital_app_parameter": "Parámetro", + "vital_app_trigger": "Activar cuando sea igual o menor que", + "vital_app_save_button": "Guardar configuración", + "vital_app_total_label": "Total (total = rem + sueño ligero + sueño profundo)", + "vital_app_duration_label": "Duration (duration = Hora que te levantaste de la cama - Hora que te acostaste en la cama)", + "vital_app_hours": "horas", + "vital_app_save_success": "Fue un éxito el guardado de tus configuraciones de App Vital", + "vital_app_save_error": "Ocurrió un error al intentar guardar tus configuraciones de App Vital" +} diff --git a/apps/web/server/createContext.ts b/apps/web/server/createContext.ts index c17325dad6..6b4c6cfdc7 100644 --- a/apps/web/server/createContext.ts +++ b/apps/web/server/createContext.ts @@ -105,7 +105,7 @@ export const createContext = async ({ req }: CreateContextOptions) => { const user = await getUserFromSession({ session, req }); const locale = user?.locale ?? getLocaleFromHeaders(req); - const i18n = await serverSideTranslations(locale, ["common"]); + const i18n = await serverSideTranslations(locale, ["common", "vital"]); return { i18n, prisma, diff --git a/packages/app-store/_components/AppConfiguration.tsx b/packages/app-store/_components/AppConfiguration.tsx new file mode 100644 index 0000000000..e7e909b2b9 --- /dev/null +++ b/packages/app-store/_components/AppConfiguration.tsx @@ -0,0 +1,19 @@ +import dynamic from "next/dynamic"; + +export const ConfigAppMap = { + vital: dynamic(() => import("../vital/components/AppConfiguration")), +}; + +export const AppConfiguration = (props: { type: string } & { credentialIds: number[] }) => { + let appName = props.type.replace(/_/g, ""); + let ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap]; + /** So we can either call it by simple name (ex. `slack`, `giphy`) instead of + * `slackmessaging`, `giphyother` while maintaining retro-compatibility. */ + if (!ConfigAppComponent) { + [appName] = props.type.split("_"); + ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap]; + } + if (!ConfigAppComponent) return null; + + return ; +}; diff --git a/packages/app-store/apiHandlers.tsx b/packages/app-store/apiHandlers.tsx index b1c6068729..c29bcc1314 100644 --- a/packages/app-store/apiHandlers.tsx +++ b/packages/app-store/apiHandlers.tsx @@ -8,6 +8,7 @@ export const apiHandlers = { slackmessaging: import("./slackmessaging/api"), stripepayment: import("./stripepayment/api"), tandemvideo: import("./tandemvideo/api"), + vital: import("./vital/api"), zoomvideo: import("@calcom/zoomvideo/api"), office365video: import("@calcom/office365video/api"), wipemycalother: import("./wipemycalother/api"), diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index 2ca1929f79..849b71cae5 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -27,6 +27,7 @@ export const InstallAppButtonMap = { metamask: dynamic(() => import("./metamask/components/InstallAppButton")), giphy: dynamic(() => import("./giphy/components/InstallAppButton")), spacebookingother: dynamic(() => import("./spacebooking/components/InstallAppButton")), + vital: dynamic(() => import("./vital/components/InstallAppButton")), }; export const InstallAppButton = ( @@ -61,3 +62,5 @@ export const InstallAppButton = ( ); return ; }; + +export { AppConfiguration } from "./_components/AppConfiguration"; diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 468086c645..24c94dd6db 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -15,6 +15,7 @@ import * as slackmessaging from "./slackmessaging"; import * as spacebooking from "./spacebooking"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; +import * as vital from "./vital"; import * as wipemycalother from "./wipemycalother"; import * as zapier from "./zapier"; import * as zoomvideo from "./zoomvideo"; @@ -35,6 +36,7 @@ const appStore = { stripepayment, spacebooking, tandemvideo, + vital, zoomvideo, wipemycalother, metamask, diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts index d8b8ed37e3..c14c136f92 100644 --- a/packages/app-store/metadata.ts +++ b/packages/app-store/metadata.ts @@ -14,6 +14,7 @@ import { metadata as slackmessaging } from "./slackmessaging/_metadata"; import { metadata as spacebooking } from "./spacebooking/_metadata"; import { metadata as stripepayment } from "./stripepayment/_metadata"; import { metadata as tandemvideo } from "./tandemvideo/_metadata"; +import { metadata as vital } from "./vital/_metadata"; import { metadata as wipemycalother } from "./wipemycalother/_metadata"; import { metadata as zapier } from "./zapier/_metadata"; import { metadata as zoomvideo } from "./zoomvideo/_metadata"; @@ -33,6 +34,7 @@ export const appStoreMetadata = { stripepayment, spacebooking, tandemvideo, + vital, zoomvideo, wipemycalother, metamask, diff --git a/packages/app-store/vital/README.mdx b/packages/app-store/vital/README.mdx new file mode 100644 index 0000000000..6762f80b43 --- /dev/null +++ b/packages/app-store/vital/README.mdx @@ -0,0 +1,6 @@ +Vital App is an app that can can help you combine your health peripherals with your calendar. + +#### Supported Actions: + +Sleep reschedule automation: Had a hard night? 🌕 +Automatically reschedule your whole day schedule based on your sleep parameters. (Setup your desired configuration on installed apps page.) diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts new file mode 100644 index 0000000000..2081cf79bf --- /dev/null +++ b/packages/app-store/vital/_metadata.ts @@ -0,0 +1,27 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Vital", + description: _package.description, + installed: true, + category: "other", + // If using static next public folder, can then be referenced from the base URL (/). + imageSrc: "/api/app-store/vital/icon.svg", + logo: "/api/app-store/vital/icon.svg", + label: "Vital", + publisher: "Vital", + rating: 5, + reviews: 69, + slug: "vital-automation", + title: "Vital", + trending: true, + type: "vital_other", + url: "https://tryvital.io", + variant: "other", + verified: true, + email: "support@tryvital.io", +} as App; + +export default metadata; diff --git a/packages/app-store/vital/api/callback.ts b/packages/app-store/vital/api/callback.ts new file mode 100644 index 0000000000..7130f6bedb --- /dev/null +++ b/packages/app-store/vital/api/callback.ts @@ -0,0 +1,41 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +/** + * This is will generate a user token for a client_user_id` + * @param req + * @param res + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const userWithMetadata = await prisma.user.findFirst({ + where: { + id: req?.session?.user.id, + }, + select: { + id: true, + metadata: true, + }, + }); + + await prisma.user.update({ + where: { + id: req?.session?.user.id, + }, + data: { + metadata: { + ...(userWithMetadata?.metadata as Prisma.JsonObject), + vitalSettings: { + ...((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject), + connected: true, + }, + }, + }, + }); + return res.redirect("/apps/installed"); + } catch (e) { + return res.status(500); + } +} diff --git a/packages/app-store/vital/api/index.ts b/packages/app-store/vital/api/index.ts new file mode 100644 index 0000000000..f2c0c8882f --- /dev/null +++ b/packages/app-store/vital/api/index.ts @@ -0,0 +1,5 @@ +export { default as token } from "./token"; +export { default as callback } from "./callback"; +export { default as webhook } from "./webhook"; +export { default as settings } from "./settings"; +export { default as save } from "./save"; diff --git a/packages/app-store/vital/api/save.ts b/packages/app-store/vital/api/save.ts new file mode 100644 index 0000000000..c3b45bad88 --- /dev/null +++ b/packages/app-store/vital/api/save.ts @@ -0,0 +1,91 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { z, ZodError } from "zod"; + +import prisma from "@calcom/prisma"; + +export type VitalSettingsResponse = { + connected: boolean; + sleepValue: number; + selectedParam: string; +}; + +const vitalSettingsUpdateSchema = z.object({ + connected: z.boolean().optional(), + selectedParam: z.string().optional(), + sleepValue: z.number().optional(), +}); + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +): Promise => { + if (req.method === "PUT" && req.session && req.session.user.id) { + const userId = req.session.user.id; + const body = req.body; + try { + const userWithMetadata = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + metadata: true, + }, + }); + const userMetadata = userWithMetadata?.metadata as Prisma.JsonObject; + const vitalSettings = + ((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject) || {}; + await prisma.user.update({ + where: { + id: userId, + }, + data: { + metadata: { + ...userMetadata, + vitalSettings: { + ...vitalSettings, + ...body, + }, + }, + }, + }); + + if (vitalSettings) { + res.status(200).json(vitalSettings); + } else { + res.status(404); + } + } catch (error) { + res.status(500); + } + } else { + res.status(400); + } + res.end(); +}; + +function validate( + handler: ( + req: NextApiRequest, + res: NextApiResponse + ) => Promise +) { + return async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "POST" || req.method === "PUT") { + try { + vitalSettingsUpdateSchema.parse(req.body); + } catch (error) { + if (error instanceof ZodError && error?.name === "ZodError") { + return res.status(400).json(error?.issues); + } + return res.status(402); + } + } else { + return res.status(405); + } + await handler(req, res); + }; +} + +export default validate(handler); diff --git a/packages/app-store/vital/api/settings.ts b/packages/app-store/vital/api/settings.ts new file mode 100644 index 0000000000..4e489a6c5b --- /dev/null +++ b/packages/app-store/vital/api/settings.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { JSONObject } from "superjson/dist/types"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET" && req.session && req.session.user.id) { + const userId = req.session.user.id; + try { + const user = await prisma?.user.findFirst({ + select: { + metadata: true, + }, + where: { + id: userId, + }, + }); + + if (user && user.metadata && (user.metadata as JSONObject)?.vitalSettings) { + res.status(200).json((user.metadata as JSONObject).vitalSettings); + } else { + res.status(404); + } + } catch (error) { + res.status(500); + } + } else { + res.status(400); + } + res.end(); +} diff --git a/packages/app-store/vital/api/sleep.create.payload.example b/packages/app-store/vital/api/sleep.create.payload.example new file mode 100644 index 0000000000..b87371d829 --- /dev/null +++ b/packages/app-store/vital/api/sleep.create.payload.example @@ -0,0 +1,23 @@ +https://docs.tryvital.io/summary-data#sleep-stream +{ + event: { + event_type: 'daily.data.sleep.created', + data: { + id: 'a27b89dc-4a43-4f59-b72a-267fa9a93c8c', + date: '1974-12-15T18:28:10+00:00', + bedtime_start: '1980-07-11T22:10:32+00:00', + bedtime_stop: '1993-03-03T04:21:02+00:00', + duration: 7225, // seconds + total: 6909, // seconds + awake: 7763, + light: 2011, // Total amount of light sleep registered during the sleep period + rem: 8106, // Total amount of REM sleep registered during the sleep period, minutes + deep: 6553, + hr_lowest: 9085, + efficiency: 1780, + latency: 8519, + source: [Object], + user_id: 'a1dfefe0-ccdb-46b8-adac-35e9ba597496' + } + } + } \ No newline at end of file diff --git a/packages/app-store/vital/api/token.ts b/packages/app-store/vital/api/token.ts new file mode 100644 index 0000000000..ae083b4c3b --- /dev/null +++ b/packages/app-store/vital/api/token.ts @@ -0,0 +1,57 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import { initVitalClient, vitalEnv } from "../lib/client"; + +/** + * This is will generate a user token for a client_user_id` + * @param req + * @param res + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Get user id + const calcomUserId = req.session?.user?.id; + if (!calcomUserId) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + + const vitalClient = await initVitalClient(); + + if (!vitalClient || !vitalEnv) + return res.status(400).json({ message: "Missing vital client, try calling `initVitalClient`" }); + + // Create a user on vital + let userVital; + try { + userVital = await vitalClient.User.create(`cal_${calcomUserId}`); + } catch (e) { + userVital = await vitalClient.User.resolve(`cal_${calcomUserId}`); + } + + try { + if (userVital?.user_id) { + await prisma.credential.create({ + data: { + type: "vital_other", + key: { userVitalId: userVital.user_id } as unknown as Prisma.InputJsonObject, + userId: calcomUserId, + appId: "vital-automation", + }, + }); + } + const token = await vitalClient.Link.create( + userVital?.user_id, + undefined, + WEBAPP_URL + "/api/integrations/vital/callback" + ); + return res.status(200).json({ + token: token.link_token, + url: `https://link.tryvital.io/?env=${vitalEnv.mode}®ion=${vitalEnv.region}`, + }); + } catch (e) { + return res.status(400).json({ error: JSON.stringify(e) }); + } +} diff --git a/packages/app-store/vital/api/webhook.ts b/packages/app-store/vital/api/webhook.ts new file mode 100644 index 0000000000..992b21b96a --- /dev/null +++ b/packages/app-store/vital/api/webhook.ts @@ -0,0 +1,158 @@ +import { BookingStatus, Prisma } from "@prisma/client"; +import dayjs from "dayjs"; +import type { NextApiRequest, NextApiResponse } from "next"; +import queue from "queue"; + +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { HttpError as HttpCode } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import { Reschedule } from "../lib"; +import { initVitalClient, vitalEnv } from "../lib/client"; + +// @Note: not being used anymore but left as example +const getOuraSleepScore = async (user_id: string, bedtime_start: Date) => { + const vitalClient = await initVitalClient(); + if (!vitalClient) throw Error("Missing vital client"); + const sleep_data = await vitalClient.Sleep.get_raw(user_id, bedtime_start, undefined, "oura"); + if (sleep_data.sleep.length === 0) { + throw Error("No sleep score found"); + } + return +sleep_data.sleep[0].data.score; +}; + +/** + * This is will generate a user token for a client_user_id` + * @param req + * @param res + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== "POST") { + throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); + } + const sig = req.headers["svix-signature"]; + if (!sig) { + throw new HttpCode({ statusCode: 400, message: "Missing svix-signature" }); + } + + const vitalClient = await initVitalClient(); + + if (!vitalClient || !vitalEnv) + return res.status(400).json({ message: "Missing vital client, try calling `initVitalClient`" }); + + const payload = JSON.stringify(req.body); + + const event: any = vitalClient.Webhooks.constructWebhookEvent( + payload, + req.headers as Record, + vitalEnv.webhook_secret as string + ); + + if (event.event_type == "daily.data.sleep.created") { + // Carry out logic here to determine what to do if sleep is less + // than 8 hours or readiness score is less than 70 + try { + if (event.data.user_id) { + const json = { userVitalId: event.data.user_id as string }; + const credential = await prisma.credential.findFirst({ + rejectOnNotFound: true, + where: { + type: "vital_other", + key: { + equals: json, + }, + }, + }); + if (!credential) { + return res.status(404).json({ message: "Missing vital credential" }); + } + + // Getting total hours of sleep seconds/60/60 = hours + const userWithMetadata = await prisma.user.findFirst({ + select: { + metadata: true, + }, + where: { + id: credential.userId as number, + }, + }); + let minimumSleepTime = 0; + let parameterFilter = ""; + const userMetadata = userWithMetadata?.metadata as Prisma.JsonObject; + const vitalSettings = + ((userWithMetadata?.metadata as Prisma.JsonObject)?.vitalSettings as Prisma.JsonObject) || {}; + if (!!userMetadata && !!vitalSettings) { + minimumSleepTime = vitalSettings.sleepValue as number; + parameterFilter = vitalSettings.parameter as string; + } else { + res.status(404).json({ message: "Vital configuration not found for user" }); + return; + } + + if (!event.data.hasOwnProperty(parameterFilter)) { + res.status(500).json({ message: "Selected param not available" }); + return; + } + const totalHoursSleep = event.data[parameterFilter] / 60 / 60; + + if (minimumSleepTime > 0 && parameterFilter !== "" && totalHoursSleep <= minimumSleepTime) { + // Trigger reschedule + try { + const todayDate = dayjs(); + const todayBookings = await prisma.booking.findMany({ + where: { + startTime: { + gte: todayDate.startOf("day").toISOString(), + }, + endTime: { + lte: todayDate.endOf("day").toISOString(), + }, + status: { + in: [BookingStatus.ACCEPTED, BookingStatus.PENDING], + }, + // @NOTE: very important filter + userId: credential?.userId, + }, + select: { + id: true, + uid: true, + userId: true, + status: true, + }, + }); + + const q = queue({ results: [] }); + if (todayBookings.length > 0) { + todayBookings.forEach((booking) => + q.push(() => { + return Reschedule(booking.uid, ""); + }) + ); + } + await q.start(); + } catch (error) { + throw new Error("Failed to reschedule bookings"); + } + } + } + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + logger.error("Failed to get sleep score"); + } + } + return res.status(200).json({ body: req.body }); + } catch (_err) { + const err = getErrorFromUnknown(_err); + console.error(`Webhook Error: ${err.message}`); + res.status(err.statusCode ?? 500).send({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.stack, + }); + return; + } +} diff --git a/packages/app-store/vital/components/AppConfiguration.tsx b/packages/app-store/vital/components/AppConfiguration.tsx new file mode 100644 index 0000000000..c1a9ca1b0f --- /dev/null +++ b/packages/app-store/vital/components/AppConfiguration.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import classNames from "@calcom/lib/classNames"; +import showToast from "@calcom/lib/notification"; +import { Button, Select } from "@calcom/ui"; + +export interface IAppConfigurationProps { + credentialIds: number[]; +} + +const saveSettings = async ({ + parameter, + sleepValue, +}: { + parameter: { label: string; value: string }; + sleepValue: number; +}) => { + try { + const response = await fetch("/api/integrations/vital/save", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sleepValue, + parameter: parameter.value, + }), + }); + if (response.ok && response.status === 200) { + return true; + } + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + } +}; + +const AppConfiguration = (props: IAppConfigurationProps) => { + const { t } = useTranslation(); + const [credentialId] = props.credentialIds; + + const options = [ + { + label: t("vital_app_total_label", { ns: "vital" }), + value: "total", + }, + { + label: t("vital_app_duration_label", { ns: "vital" }), + value: "duration", + }, + ]; + const [selectedParam, setSelectedParam] = useState<{ label: string; value: string }>(options[0]); + const [touchedForm, setTouchedForm] = useState(false); + const defaultSleepValue = 0; + const [sleepValue, setSleepValue] = useState(defaultSleepValue); + const [connected, setConnected] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + useEffect(() => { + async function getVitalsConfig() { + const response = await fetch("/api/integrations/vital/settings", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (response.status === 200) { + const vitalSettings: { + connected: boolean; + parameter: string; + sleepValue: number; + } = await response.json(); + + if (vitalSettings && vitalSettings.connected) { + setConnected(vitalSettings.connected); + } + if (vitalSettings.sleepValue && vitalSettings.parameter) { + const selectedParam = options.find((item) => item.value === vitalSettings.parameter); + if (selectedParam) { + setSelectedParam(selectedParam); + } + setSleepValue(vitalSettings.sleepValue); + } + } + } + getVitalsConfig(); + }, []); + + if (!credentialId) { + return <>; + } + + const disabledSaveButton = !touchedForm || sleepValue === 0; + return ( +
+

+ + {t("connected_vital_app", { ns: "vital" })} Vital App: {connected ? "Yes" : "No"} + +

+
+

+ {t("vital_app_sleep_automation", { ns: "vital" })} +

+

{t("vital_app_automation_description", { ns: "vital" })}

+ +
+
+
+ +
+
+ { + setSleepValue(Number(e.currentTarget.value)); + setTouchedForm(true); + }} + className={ + "pr-12shadow-sm mt-1 block w-full rounded-sm border border-gray-300 py-2 pl-6 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + } + /> +

+ {t("vital_app_hours", { ns: "vital" })} +

+
+
+ +
+ +
+
+ ); +}; + +export default AppConfiguration; diff --git a/packages/app-store/vital/components/InstallAppButton.tsx b/packages/app-store/vital/components/InstallAppButton.tsx new file mode 100644 index 0000000000..0c29769f00 --- /dev/null +++ b/packages/app-store/vital/components/InstallAppButton.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; + +import { InstallAppButtonProps } from "../../types"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const getLinkToken = async () => { + const res = await fetch("/api/integrations/vital/token", { + method: "POST", + body: JSON.stringify({}), + headers: { + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new Error("Failed to get link token"); + } + return await res.json(); + }; + const [loading, setLoading] = useState(false); + return ( + <> + {props.render({ + onClick() { + setLoading(true); + getLinkToken() + .then((data) => { + setLoading(false); + window.open(`${data.url}&token=${data.token}`, "_self"); + }) + .catch((error) => { + setLoading(false); + console.error(error); + }); + }, + loading: loading, + })} + + ); +} diff --git a/packages/app-store/vital/components/index.ts b/packages/app-store/vital/components/index.ts new file mode 100644 index 0000000000..dcb4515bc9 --- /dev/null +++ b/packages/app-store/vital/components/index.ts @@ -0,0 +1,2 @@ +export { default as AppConfiguration } from "./AppConfiguration"; +export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/vital/index.ts b/packages/app-store/vital/index.ts new file mode 100644 index 0000000000..8160a6e147 --- /dev/null +++ b/packages/app-store/vital/index.ts @@ -0,0 +1,4 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export * as components from "./components"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/vital/lib/client.ts b/packages/app-store/vital/lib/client.ts new file mode 100644 index 0000000000..0196a8ec5d --- /dev/null +++ b/packages/app-store/vital/lib/client.ts @@ -0,0 +1,34 @@ +import { VitalClient } from "@tryvital/vital-node"; +import type { ClientConfig } from "@tryvital/vital-node/dist/lib/models"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; + +type VitalEnv = ClientConfig & { + mode: string; + webhook_secret: string; +}; + +export let vitalClient: VitalClient | null = null; +export let vitalEnv: VitalEnv | null = null; + +export async function initVitalClient(): Promise { + if (vitalClient) return vitalClient; + const appKeys = (await getAppKeysFromSlug("vital-automation")) as unknown as VitalEnv; + if ( + typeof appKeys !== "object" || + typeof appKeys.api_key !== "string" || + typeof appKeys.webhook_secret !== "string" || + typeof appKeys.region !== "string" || + typeof appKeys.mode !== "string" + ) + throw Error("Missing properties in vital-automation DB keys"); + vitalEnv = appKeys; + vitalClient = new VitalClient({ + region: appKeys.region, + api_key: appKeys.api_key || "", + environment: (appKeys.mode as ClientConfig["environment"]) || "sandbox", + }); + return vitalClient; +} + +export default vitalClient; diff --git a/packages/app-store/vital/lib/emailManager.ts b/packages/app-store/vital/lib/emailManager.ts new file mode 100644 index 0000000000..abf2f52063 --- /dev/null +++ b/packages/app-store/vital/lib/emailManager.ts @@ -0,0 +1,35 @@ +import { CalendarEvent } from "@calcom/types/Calendar"; + +import AttendeeRequestRescheduledEmail from "./templates/attendee-request-reschedule-email"; +import OrganizerRequestRescheduledEmail from "./templates/organizer-request-reschedule-email"; + +export const sendRequestRescheduleEmail = async ( + calEvent: CalendarEvent, + metadata: { rescheduleLink: string } +) => { + const emailsToSend: Promise[] = []; + + emailsToSend.push( + new Promise((resolve, reject) => { + try { + const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata); + resolve(requestRescheduleEmail.sendEmail()); + } catch (e) { + reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e)); + } + }) + ); + + emailsToSend.push( + new Promise((resolve, reject) => { + try { + const requestRescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent, metadata); + resolve(requestRescheduleEmail.sendEmail()); + } catch (e) { + reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e)); + } + }) + ); + + await Promise.all(emailsToSend); +}; diff --git a/packages/app-store/vital/lib/emailServerConfig.ts b/packages/app-store/vital/lib/emailServerConfig.ts new file mode 100644 index 0000000000..a51d0c7e4f --- /dev/null +++ b/packages/app-store/vital/lib/emailServerConfig.ts @@ -0,0 +1,34 @@ +import SendmailTransport from "nodemailer/lib/sendmail-transport"; +import SMTPConnection from "nodemailer/lib/smtp-connection"; + +function detectTransport(): SendmailTransport.Options | SMTPConnection.Options | string { + if (process.env.EMAIL_SERVER) { + return process.env.EMAIL_SERVER; + } + + if (process.env.EMAIL_SERVER_HOST) { + const port = parseInt(process.env.EMAIL_SERVER_PORT!); + const transport = { + host: process.env.EMAIL_SERVER_HOST, + port, + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + secure: port === 465, + }; + + return transport; + } + + return { + sendmail: true, + newline: "unix", + path: "/usr/sbin/sendmail", + }; +} + +export const serverConfig = { + transport: detectTransport(), + from: process.env.EMAIL_FROM, +}; diff --git a/packages/app-store/vital/lib/index.ts b/packages/app-store/vital/lib/index.ts new file mode 100644 index 0000000000..7eac0f92fe --- /dev/null +++ b/packages/app-store/vital/lib/index.ts @@ -0,0 +1 @@ +export { default as Reschedule } from "./reschedule"; diff --git a/packages/app-store/vital/lib/reschedule.ts b/packages/app-store/vital/lib/reschedule.ts new file mode 100644 index 0000000000..5602e548e0 --- /dev/null +++ b/packages/app-store/vital/lib/reschedule.ts @@ -0,0 +1,170 @@ +import { BookingStatus, User, Booking, BookingReference } from "@prisma/client"; +import dayjs from "dayjs"; +import type { TFunction } from "next-i18next"; + +import EventManager from "@calcom/core/EventManager"; +import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; +import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; +import { deleteMeeting } from "@calcom/core/videoClient"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import prisma from "@calcom/prisma"; +import { Person } from "@calcom/types/Calendar"; + +import { getCalendar } from "../../_utils/getCalendar"; +import { sendRequestRescheduleEmail } from "./emailManager"; + +type PersonAttendeeCommonFields = Pick; + +const Reschedule = async (bookingUid: string, cancellationReason: string) => { + const bookingToReschedule = await prisma.booking.findFirst({ + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + userId: true, + eventTypeId: true, + location: true, + attendees: true, + references: true, + user: { + select: { + id: true, + email: true, + name: true, + timeZone: true, + locale: true, + username: true, + credentials: true, + destinationCalendar: true, + }, + }, + }, + rejectOnNotFound: true, + where: { + uid: bookingUid, + NOT: { + status: { + in: [BookingStatus.CANCELLED, BookingStatus.REJECTED], + }, + }, + }, + }); + + if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) { + const userOwner = bookingToReschedule.user; + const event = await prisma.eventType.findFirst({ + select: { + title: true, + users: true, + schedulingType: true, + }, + rejectOnNotFound: true, + where: { + id: bookingToReschedule.eventTypeId, + }, + }); + await prisma.booking.update({ + where: { + id: bookingToReschedule.id, + }, + data: { + rescheduled: true, + cancellationReason, + status: BookingStatus.CANCELLED, + updatedAt: dayjs().toISOString(), + }, + }); + const [mainAttendee] = bookingToReschedule.attendees; + // @NOTE: Should we assume attendees language? + const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common"); + const usersToPeopleType = ( + users: PersonAttendeeCommonFields[], + selectedLanguage: TFunction + ): Person[] => { + return users?.map((user) => { + return { + email: user.email || "", + name: user.name || "", + username: user?.username || "", + language: { translate: selectedLanguage, locale: user.locale || "en" }, + timeZone: user?.timeZone, + }; + }); + }; + const userOwnerTranslation = await getTranslation(userOwner.locale ?? "en", "common"); + const [userOwnerAsPeopleType] = usersToPeopleType([userOwner], userOwnerTranslation); + const builder = new CalendarEventBuilder(); + builder.init({ + title: bookingToReschedule.title, + type: event.title, + startTime: bookingToReschedule.startTime.toISOString(), + endTime: bookingToReschedule.endTime.toISOString(), + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + organizer: userOwnerAsPeopleType, + }); + const director = new CalendarEventDirector(); + director.setBuilder(builder); + director.setExistingBooking(bookingToReschedule as unknown as Booking); + director.setCancellationReason(cancellationReason); + await director.buildForRescheduleEmail(); + // Handling calendar and videos cancellation + // This can set previous time as available, until virtual calendar is done + const credentialsMap = new Map(); + userOwner.credentials.forEach((credential) => { + credentialsMap.set(credential.type, credential); + }); + const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter( + (ref) => !!credentialsMap.get(ref.type) + ); + try { + bookingRefsFiltered.forEach((bookingRef) => { + if (bookingRef.uid) { + if (bookingRef.type.endsWith("_calendar")) { + const calendar = getCalendar(credentialsMap.get(bookingRef.type)); + return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent); + } else if (bookingRef.type.endsWith("_video")) { + return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid); + } + } + }); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + } + // Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it + try { + const eventManager = new EventManager({ + credentials: userOwner.credentials, + destinationCalendar: userOwner.destinationCalendar, + }); + builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`; + await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + } + + // Send emails + try { + await sendRequestRescheduleEmail(builder.calendarEvent, { + rescheduleLink: builder.rescheduleLink, + }); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + } + return true; + } +}; + +export default Reschedule; diff --git a/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts b/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts new file mode 100644 index 0000000000..6052ef19c4 --- /dev/null +++ b/packages/app-store/vital/lib/templates/attendee-request-reschedule-email.ts @@ -0,0 +1,208 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; +import { createEvent, DateArray, Person } from "ics"; + +import { getCancelLink } from "@calcom/lib/CalEventParser"; +import { CalendarEvent } from "@calcom/types/Calendar"; + +import BaseTemplate from "./base-template"; +import { + emailHead, + emailSchedulingBodyHeader, + emailBodyLogo, + emailScheduledBodyHeaderContent, + emailSchedulingBodyDivider, +} from "./common"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class AttendeeRequestRescheduledEmail extends BaseTemplate { + private metadata: { rescheduleLink: string }; + constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) { + super(calEvent); + this.metadata = metadata; + } + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.attendees[0].email]; + + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.organizer.language.translate("requested_to_reschedule_subject_attendee", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + // @OVERRIDE + protected getiCalEventAsString(): string | undefined { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calendso/ics", + title: this.calEvent.organizer.language.translate("ics_event_title", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + }), + description: this.getTextBody(), + duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, + organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: this.calEvent.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + })), + status: "CANCELLED", + method: "CANCEL", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + // @OVERRIDE + protected getWhen(): string { + return ` +

+
+

${this.calEvent.organizer.language.translate("when")}

+

+ ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format( + "YYYY" + )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )} (${this.getTimezone()}) +

+
`; + } + + protected getTextBody(): string { + return ` +${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")} +${this.calEvent.organizer.language.translate("request_reschedule_subtitle", { + organizer: this.calEvent.organizer.name, +})}, +${this.getWhat()} +${this.getWhen()} +${this.getAdditionalNotes()} +${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")} +${getCancelLink(this.calEvent)} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + ${emailScheduledBodyHeaderContent( + this.calEvent.organizer.language.translate("request_reschedule_title_attendee"), + this.calEvent.organizer.language.translate("request_reschedule_subtitle", { + organizer: this.calEvent.organizer.name, + }) + )} + ${emailSchedulingBodyDivider()} + +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ ${emailSchedulingBodyDivider()} + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+
+ +
+
+ ${emailBodyLogo()} + +
+ + + `; + } +} diff --git a/packages/app-store/vital/lib/templates/base-template.ts b/packages/app-store/vital/lib/templates/base-template.ts new file mode 100644 index 0000000000..cf0479bcf8 --- /dev/null +++ b/packages/app-store/vital/lib/templates/base-template.ts @@ -0,0 +1,409 @@ +import dayjs, { Dayjs } from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; +import { createEvent, DateArray, Person } from "ics"; +import nodemailer from "nodemailer"; + +import { getAppName } from "@calcom/app-store/utils"; +import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { serverConfig } from "../emailServerConfig"; +import { + emailHead, + emailSchedulingBodyHeader, + emailBodyLogo, + emailScheduledBodyHeaderContent, + emailSchedulingBodyDivider, + linkIcon, +} from "./common"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerScheduledEmail { + calEvent: CalendarEvent; + + constructor(calEvent: CalendarEvent) { + this.calEvent = calEvent; + } + + public sendEmail() { + new Promise((resolve, reject) => + nodemailer + .createTransport(this.getMailerOptions().transport) + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); + } else { + resolve(info); + } + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); + } + + protected getiCalEventAsString(): string | undefined { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calendso/ics", + title: this.calEvent.organizer.language.translate("ics_event_title", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + }), + description: this.getTextBody(), + duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, + organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: this.calEvent.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + })), + status: "CONFIRMED", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.organizer.language.translate("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + protected getMailerOptions() { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + protected getTextBody(): string { + return ` +${this.calEvent.organizer.language.translate("new_event_scheduled")} +${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")} + +${getRichDescription(this.calEvent)} +`.trim(); + } + + protected printNodeMailerError(error: Error): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.organizer.language.translate("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("checkCircle")} + ${emailScheduledBodyHeaderContent( + this.calEvent.organizer.language.translate("new_event_scheduled"), + this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees") + )} + ${emailSchedulingBodyDivider()} + +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getLocation()} + ${this.getDescription()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ ${emailSchedulingBodyDivider()} + +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getManageLink()} +
+
+
+ +
+
+ ${emailBodyLogo()} + +
+ + + `; + } + + protected getManageLink(): string { + const manageText = this.calEvent.organizer.language.translate("manage_this_event"); + return `

${this.calEvent.organizer.language.translate( + "need_to_reschedule_or_cancel" + )}

${manageText}

`; + } + + protected getWhat(): string { + return ` +
+

${this.calEvent.organizer.language.translate("what")}

+

${this.calEvent.type}

+
`; + } + + protected getWhen(): string { + return ` +

+
+

${this.calEvent.organizer.language.translate("when")}

+

+ ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format( + "YYYY" + )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )} (${this.getTimezone()}) +

+
`; + } + + protected getWho(): string { + const attendees = this.calEvent.attendees + .map((attendee) => { + return `
${ + attendee?.name || `${this.calEvent.organizer.language.translate("guest")}` + } ${ + attendee.email + }
`; + }) + .join(""); + + const organizer = `
${ + this.calEvent.organizer.name + } - ${this.calEvent.organizer.language.translate( + "organizer" + )} ${this.calEvent.organizer.email}
`; + + return ` +

+
+

${this.calEvent.organizer.language.translate("who")}

+ ${organizer + attendees} +
`; + } + + protected getAdditionalNotes(): string { + if (!this.calEvent.additionalNotes) return ""; + return ` +

+
+

${this.calEvent.organizer.language.translate("additional_notes")}

+

${ + this.calEvent.additionalNotes + }

+
+ `; + } + + protected getDescription(): string { + if (!this.calEvent.description) return ""; + return ` +

+
+

${this.calEvent.organizer.language.translate("description")}

+

${ + this.calEvent.description + }

+
+ `; + } + + protected getLocation(): string { + let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : ""; + + if (this.calEvent.location && this.calEvent.location.includes("integrations:")) { + const location = this.calEvent.location.split(":")[1]; + providerName = location[0].toUpperCase() + location.slice(1); + } + + // If location its a url, probably we should be validating it with a custom library + if (this.calEvent.location && /^https?:\/\//.test(this.calEvent.location)) { + providerName = this.calEvent.location; + } + + if (this.calEvent.videoCallData) { + const meetingId = this.calEvent.videoCallData.id; + const meetingPassword = this.calEvent.videoCallData.password; + const meetingUrl = this.calEvent.videoCallData.url; + + return ` +

+
+

${this.calEvent.organizer.language.translate("where")}

+

${providerName} ${ + meetingUrl && + `` + }

+ ${ + meetingId && + `
${this.calEvent.organizer.language.translate( + "meeting_id" + )}: ${meetingId}
` + } + ${ + meetingPassword && + `
${this.calEvent.organizer.language.translate( + "meeting_password" + )}: ${meetingPassword}
` + } + ${ + meetingUrl && + `
${this.calEvent.organizer.language.translate( + "meeting_url" + )}: ${meetingUrl}
` + } +
+ `; + } + + if (this.calEvent.additionInformation?.hangoutLink) { + const hangoutLink: string = this.calEvent.additionInformation.hangoutLink; + + return ` +

+
+

${this.calEvent.organizer.language.translate("where")}

+

${providerName} ${ + hangoutLink && + `` + }

+ +
+ `; + } + + return ` +

+
+

${this.calEvent.organizer.language.translate("where")}

+

${ + providerName || this.calEvent.location + }

+
+ `; + } + + protected getTimezone(): string { + return this.calEvent.organizer.timeZone; + } + + protected getOrganizerStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.getTimezone()); + } + + protected getOrganizerEnd(): Dayjs { + return dayjs(this.calEvent.endTime).tz(this.getTimezone()); + } +} diff --git a/packages/app-store/vital/lib/templates/common/body-logo.ts b/packages/app-store/vital/lib/templates/common/body-logo.ts new file mode 100644 index 0000000000..3b5b143e8a --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/body-logo.ts @@ -0,0 +1,44 @@ +import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants"; + +export const emailBodyLogo = (): string => { + const image = IS_PRODUCTION + ? BASE_URL + "/emails/CalLogo@2x.png" + : "https://app.cal.com/emails/CalLogo@2x.png"; + + return ` + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ `; +}; diff --git a/packages/app-store/vital/lib/templates/common/head.ts b/packages/app-store/vital/lib/templates/common/head.ts new file mode 100644 index 0000000000..be22403827 --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/head.ts @@ -0,0 +1,91 @@ +export const emailHead = (headerContent: string): string => { + return ` + + ${headerContent} + + + + + + + + + + + + + + + + + `; +}; diff --git a/packages/app-store/vital/lib/templates/common/index.ts b/packages/app-store/vital/lib/templates/common/index.ts new file mode 100644 index 0000000000..686d871f91 --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/index.ts @@ -0,0 +1,6 @@ +export { emailHead } from "./head"; +export { emailSchedulingBodyHeader } from "./scheduling-body-head"; +export { emailBodyLogo } from "./body-logo"; +export { emailScheduledBodyHeaderContent } from "./scheduling-body-head-content"; +export { emailSchedulingBodyDivider } from "./scheduling-body-divider"; +export { linkIcon } from "./link-icon"; diff --git a/packages/app-store/vital/lib/templates/common/link-icon.ts b/packages/app-store/vital/lib/templates/common/link-icon.ts new file mode 100644 index 0000000000..434ae2cbcb --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/link-icon.ts @@ -0,0 +1,5 @@ +import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants"; + +export const linkIcon = (): string => { + return IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.png" : "https://app.cal.com/emails/linkIcon.png"; +}; diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts new file mode 100644 index 0000000000..b8723c3e7b --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/scheduling-body-divider.ts @@ -0,0 +1,31 @@ +export const emailSchedulingBodyDivider = (): string => { + return ` + +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ `; +}; diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts new file mode 100644 index 0000000000..31515fc760 --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/scheduling-body-head-content.ts @@ -0,0 +1,33 @@ +export const emailScheduledBodyHeaderContent = (title: string, subtitle: string): string => { + return ` + +
+ + + + + + +
+ +
+ + + + + + + + + +
+
${title}
+
+
${subtitle}
+
+
+ +
+
+ `; +}; diff --git a/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts b/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts new file mode 100644 index 0000000000..fc715d2a26 --- /dev/null +++ b/packages/app-store/vital/lib/templates/common/scheduling-body-head.ts @@ -0,0 +1,71 @@ +import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants"; + +export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle"; + +export const getHeadImage = (headerType: BodyHeadType): string => { + switch (headerType) { + case "checkCircle": + return IS_PRODUCTION + ? BASE_URL + "/emails/checkCircle@2x.png" + : "https://app.cal.com/emails/checkCircle@2x.png"; + case "xCircle": + return IS_PRODUCTION + ? BASE_URL + "/emails/xCircle@2x.png" + : "https://app.cal.com/emails/xCircle@2x.png"; + case "calendarCircle": + return IS_PRODUCTION + ? BASE_URL + "/emails/calendarCircle@2x.png" + : "https://app.cal.com/emails/calendarCircle@2x.png"; + } +}; + +export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => { + const image = getHeadImage(headerType); + + return ` + +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ `; +}; diff --git a/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts b/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts new file mode 100644 index 0000000000..716d47d3d1 --- /dev/null +++ b/packages/app-store/vital/lib/templates/organizer-request-reschedule-email.ts @@ -0,0 +1,188 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import timezone from "dayjs/plugin/timezone"; +import toArray from "dayjs/plugin/toArray"; +import utc from "dayjs/plugin/utc"; +import { createEvent, DateArray, Person } from "ics"; + +import { getCancelLink } from "@calcom/lib/CalEventParser"; +import { CalendarEvent } from "@calcom/types/Calendar"; + +import BaseTemplate from "./base-template"; +import { + emailHead, + emailSchedulingBodyHeader, + emailBodyLogo, + emailScheduledBodyHeaderContent, + emailSchedulingBodyDivider, +} from "./common"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); +dayjs.extend(toArray); + +export default class OrganizerRequestRescheduledEmail extends BaseTemplate { + private metadata: { rescheduleLink: string }; + constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) { + super(calEvent); + this.metadata = metadata; + } + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + + return { + icalEvent: { + filename: "event.ics", + content: this.getiCalEventAsString(), + }, + from: `Cal.com <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + })}`, + html: this.getHtmlBody(), + text: this.getTextBody(), + }; + } + + // @OVERRIDE + protected getiCalEventAsString(): string | undefined { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calendso/ics", + title: this.calEvent.organizer.language.translate("ics_event_title", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + }), + description: this.getTextBody(), + duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, + organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: this.calEvent.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + })), + status: "CANCELLED", + method: "CANCEL", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + // @OVERRIDE + protected getWhen(): string { + return ` +

+
+

${this.calEvent.organizer.language.translate("when")}

+

+ ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format( + "YYYY" + )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )} (${this.getTimezone()}) +

+
`; + } + + protected getTextBody(): string { + return ` +${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", { + attendee: this.calEvent.attendees[0].name, +})} +${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", { + attendee: this.calEvent.attendees[0].name, +})}, +${this.getWhat()} +${this.getWhen()} +${this.getLocation()} +${this.getAdditionalNotes()} +${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")} +${getCancelLink(this.calEvent)} +`.replace(/(<([^>]+)>)/gi, ""); + } + + protected getHtmlBody(): string { + const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.attendees[0].name, + date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format( + "h:mma" + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("dddd").toLowerCase() + )}, ${this.calEvent.organizer.language.translate( + this.getOrganizerStart().format("MMMM").toLowerCase() + )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`, + }); + + return ` + + + ${emailHead(headerContent)} + +
+ ${emailSchedulingBodyHeader("calendarCircle")} + ${emailScheduledBodyHeaderContent( + this.calEvent.organizer.language.translate("request_reschedule_title_organizer", { + attendee: this.calEvent.attendees[0].name, + }), + this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", { + attendee: this.calEvent.attendees[0].name, + }) + )} + ${emailSchedulingBodyDivider()} + +
+ + + + + + +
+ +
+ + + + + + +
+
+ ${this.getWhat()} + ${this.getWhen()} + ${this.getWho()} + ${this.getAdditionalNotes()} +
+
+
+ +
+
+ ${emailBodyLogo()} + +
+ + + `; + } +} diff --git a/packages/app-store/vital/package.json b/packages/app-store/vital/package.json new file mode 100644 index 0000000000..f395909bc9 --- /dev/null +++ b/packages/app-store/vital/package.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/vital", + "version": "0.1.0", + "main": "./index.ts", + "description": "Connect your health data or wearables to trigger actions on your calendar.", + "dependencies": { + "@calcom/prisma": "*", + "@tryvital/vital-node": "^1.3.6", + "queue": "^6.0.2" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/vital/static/icon.svg b/packages/app-store/vital/static/icon.svg new file mode 100644 index 0000000000..de067c513b --- /dev/null +++ b/packages/app-store/vital/static/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/lib/hooks/useLocale.ts b/packages/lib/hooks/useLocale.ts index 2fae0c7b8d..f0611553d9 100644 --- a/packages/lib/hooks/useLocale.ts +++ b/packages/lib/hooks/useLocale.ts @@ -1,7 +1,7 @@ import { useTranslation } from "next-i18next"; -export const useLocale = () => { - const { i18n, t } = useTranslation("common"); +export const useLocale = (namespace: Parameters[0] = "common") => { + const { i18n, t } = useTranslation(namespace); return { i18n, diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 247bd7656b..905eb00907 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -5,6 +5,7 @@ import prisma from "."; require("dotenv").config({ path: "../../.env.appStore" }); async function createApp( + /** The App identifier in the DB also used for public page in `/apps/[slug]` */ slug: Prisma.AppCreateInput["slug"], /** The directory name for `/packages/app-store/[dirName]` */ dirName: Prisma.AppCreateInput["dirName"], @@ -86,6 +87,14 @@ async function main() { }); } await createApp("space-booking", "spacebooking", ["other"], "spacebooking_other"); + if (process.env.VITAL_API_KEY && process.env.VITAL_WEBHOOK_SECRET) { + await createApp("vital-automation", "vital", ["other"], "vital_other", { + mode: process.env.VITAL_DEVELOPMENT_MODE || "sandbox", + region: process.env.VITAL_REGION || "us", + api_key: process.env.VITAL_API_KEY, + webhook_secret: process.env.VITAL_WEBHOOK_SECRET, + }); + } await createApp("zapier", "zapier", ["other"], "zapier_other"); // Web3 apps await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video"); diff --git a/packages/ui/form/Select.tsx b/packages/ui/form/Select.tsx new file mode 100644 index 0000000000..59875aeab6 --- /dev/null +++ b/packages/ui/form/Select.tsx @@ -0,0 +1,63 @@ +import ReactSelect, { components, GroupBase, InputProps, Props } from "react-select"; + +import classNames from "@calcom/lib/classNames"; + +export type SelectProps< + Option, + IsMulti extends boolean = false, + Group extends GroupBase