From 8c9096b55b64564340a2e68c369cbe234adb93f1 Mon Sep 17 00:00:00 2001 From: alannnc Date: Fri, 6 May 2022 12:21:30 -0500 Subject: [PATCH] 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