diff --git a/.env.example b/.env.example index a7731d6b4c..2a9bfb6b43 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ MS_GRAPH_CLIENT_SECRET= ZOOM_CLIENT_ID= ZOOM_CLIENT_SECRET= +#Used for the Daily integration +DAILY_API_KEY= + # E-mail settings # Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..7a9dfa044d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 3a7ba4783f..86e200dd30 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,13 @@ Contributions are what make the open source community such an amazing place to b 12. Click "Done". 13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings. +## Obtaining Daily API Credentials + + 1. Open [Daily](https://www.daily.co/) and sign into your account. + 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab. + 3. Copy your API key. + 4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file. + ## License diff --git a/components/booking/pages/BookingPage.tsx b/components/booking/pages/BookingPage.tsx index 05ad3b96bb..7d2cd156bd 100644 --- a/components/booking/pages/BookingPage.tsx +++ b/components/booking/pages/BookingPage.tsx @@ -71,6 +71,7 @@ const BookingPage = (props: BookingPageProps) => { [LocationType.Phone]: "Phone call", [LocationType.GoogleMeet]: "Google Meet", [LocationType.Zoom]: "Zoom Video", + [LocationType.Daily]: "Daily.co Video", }; const _bookingHandler = (event) => { diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 54e9eb8386..752dd15804 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -80,13 +80,17 @@ export default class CalEventParser { /** * Conditionally returns the event's location. When VideoCallData is set, * it returns the meeting url. Otherwise, the regular location is returned. - * + * For Daily video calls returns the direct link * @protected */ protected getLocation(): string | undefined { + const isDaily = this.calEvent.location === "integrations:daily"; if (this.optionalVideoCallData) { return this.optionalVideoCallData.url; } + if (isDaily) { + return process.env.BASE_URL + "/call/" + this.getUid(); + } return this.calEvent.location; } diff --git a/lib/dailyVideoClient.ts b/lib/dailyVideoClient.ts new file mode 100644 index 0000000000..4e37130c62 --- /dev/null +++ b/lib/dailyVideoClient.ts @@ -0,0 +1,239 @@ +import { Credential } from "@prisma/client"; +import short from "short-uuid"; +import { v5 as uuidv5 } from "uuid"; + +import CalEventParser from "@lib/CalEventParser"; +import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; +import { getIntegrationName } from "@lib/emails/helpers"; +import { EventResult } from "@lib/events/EventManager"; +import logger from "@lib/logger"; + +import { CalendarEvent } from "./calendarClient"; +import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; +import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; +import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; +import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; + +const log = logger.getChildLogger({ prefix: ["[lib] dailyVideoClient"] }); + +const translator = short(); + +export interface DailyVideoCallData { + type: string; + id: string; + password: string; + url: string; +} + +function handleErrorsJson(response) { + if (!response.ok) { + response.json().then(console.log); + throw Error(response.statusText); + } + return response.json(); +} + +const dailyCredential = process.env.DAILY_API_KEY; + +interface DailyVideoApiAdapter { + dailyCreateMeeting(event: CalendarEvent): Promise; + + dailyUpdateMeeting(uid: string, event: CalendarEvent); + + dailyDeleteMeeting(uid: string): Promise; + + getAvailability(dateFrom, dateTo): Promise; +} + +const DailyVideo = (credential): DailyVideoApiAdapter => { + const translateEvent = (event: CalendarEvent) => { + // Documentation at: https://docs.daily.co/reference#list-rooms + // added a 1 hour buffer for room expiration and room entry + const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60; + const nbf = Math.round(new Date(event.startTime).getTime() / 1000) - 60 * 60; + return { + privacy: "private", + properties: { + enable_new_call_ui: true, + enable_prejoin_ui: true, + enable_knocking: true, + enable_screenshare: true, + enable_chat: true, + exp: exp, + nbf: nbf, + }, + }; + }; + + return { + getAvailability: () => { + return credential; + }, + dailyCreateMeeting: (event: CalendarEvent) => + fetch("https://api.daily.co/v1/rooms", { + method: "POST", + headers: { + Authorization: "Bearer " + dailyCredential, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsJson), + dailyDeleteMeeting: (uid: string) => + fetch("https://api.daily.co/v1/rooms/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + dailyCredential, + }, + }).then(handleErrorsJson), + dailyUpdateMeeting: (uid: string, event: CalendarEvent) => + fetch("https://api.daily.co/v1/rooms/" + uid, { + method: "POST", + headers: { + Authorization: "Bearer " + dailyCredential, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsJson), + }; +}; + +// factory +const videoIntegrations = (withCredentials): DailyVideoApiAdapter[] => + withCredentials + .map((cred) => { + return DailyVideo(cred); + }) + .filter(Boolean); + +const getBusyVideoTimes: (withCredentials) => Promise = (withCredentials) => + Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) => + results.reduce((acc, availability) => acc.concat(availability), []) + ); + +const dailyCreateMeeting = async ( + credential: Credential, + calEvent: CalendarEvent, + maybeUid: string = null +): Promise => { + const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); + const uid: string = parser.getUid(); + + if (!credential) { + throw new Error( + "Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set." + ); + } + + let success = true; + + const creationResult = await videoIntegrations([credential])[0] + .dailyCreateMeeting(calEvent) + .catch((e) => { + log.error("createMeeting failed", e, calEvent); + success = false; + }); + + const currentRoute = process.env.BASE_URL; + + const videoCallData: DailyVideoCallData = { + type: "Daily.co Video", + id: creationResult.name, + password: creationResult.password, + url: currentRoute + "/call/" + uid, + }; + + const entryPoint: EntryPoint = { + entryPointType: getIntegrationName(videoCallData), + uri: videoCallData.url, + label: "Enter Meeting", + pin: "", + }; + + const additionInformation: AdditionInformation = { + entryPoints: [entryPoint], + }; + + const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation); + const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation); + + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e); + } + + if (!creationResult || !creationResult.disableConfirmationEmail) { + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } + } + + return { + type: "daily", + success, + uid, + createdEvent: creationResult, + originalEvent: calEvent, + }; +}; + +const dailyUpdateMeeting = async ( + credential: Credential, + uidToUpdate: string, + calEvent: CalendarEvent +): Promise => { + const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); + + if (!credential) { + throw new Error( + "Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set." + ); + } + + let success = true; + + const updateResult = credential + ? await videoIntegrations([credential])[0] + .dailyUpdateMeeting(uidToUpdate, calEvent) + .catch((e) => { + log.error("updateMeeting failed", e, calEvent); + success = false; + }) + : null; + + const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); + const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e); + } + + if (!updateResult || !updateResult.disableConfirmationEmail) { + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } + } + + return { + type: credential.type, + success, + uid: newUid, + updatedEvent: updateResult, + originalEvent: calEvent, + }; +}; + +const dailyDeleteMeeting = (credential: Credential, uid: string): Promise => { + if (credential) { + return videoIntegrations([credential])[0].dailyDeleteMeeting(uid); + } + + return Promise.resolve({}); +}; + +export { getBusyVideoTimes, dailyCreateMeeting, dailyUpdateMeeting, dailyDeleteMeeting }; diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts index 372918a0aa..26ea18d089 100644 --- a/lib/emails/VideoEventAttendeeMail.ts +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -25,11 +25,21 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { * @protected */ protected getAdditionalBody(): string { - return ` + const meetingPassword = this.videoCallData.password; + const meetingId = getFormattedMeetingId(this.videoCallData); + + if (meetingId && meetingPassword) { + return ` Video call provider: ${getIntegrationName(this.videoCallData)}
Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
Meeting Password: ${this.videoCallData.password}
Meeting URL: ${this.videoCallData.url}
`; + } + + return ` + Video call provider: ${getIntegrationName(this.videoCallData)}
+ Meeting URL: ${this.videoCallData.url}
+ `; } } diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts index 3c6528c2b4..9a63a645d9 100644 --- a/lib/emails/VideoEventOrganizerMail.ts +++ b/lib/emails/VideoEventOrganizerMail.ts @@ -26,11 +26,19 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail { * @protected */ protected getAdditionalBody(): string { + const meetingPassword = this.videoCallData.password; + const meetingId = getFormattedMeetingId(this.videoCallData); // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description. - return ` + if (meetingPassword && meetingId) { + return ` Video call provider: ${getIntegrationName(this.videoCallData)}
Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
Meeting Password: ${this.videoCallData.password}
+Meeting URL: ${this.videoCallData.url}
+ `; + } + return ` +Video call provider: ${getIntegrationName(this.videoCallData)}
Meeting URL: ${this.videoCallData.url}
`; } diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 436c1ba79a..1c444fe67b 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -4,6 +4,7 @@ import merge from "lodash.merge"; import { v5 as uuidv5 } from "uuid"; import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; +import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient"; import EventAttendeeMail from "@lib/emails/EventAttendeeMail"; import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; import { LocationType } from "@lib/location"; @@ -43,6 +44,9 @@ interface GetLocationRequestFromIntegrationRequest { location: string; } +//const to idenfity a daily event location +const dailyLocation = "integrations:daily"; + export default class EventManager { calendarCredentials: Array; videoCredentials: Array; @@ -55,6 +59,19 @@ export default class EventManager { constructor(credentials: Array) { this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar")); this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video")); + + //for Daily.co video, temporarily pushes a credential for the daily-video-client + + const hasDailyIntegration = process.env.DAILY_API_KEY; + const dailyCredential: Credential = { + id: +new Date().getTime(), + type: "daily_video", + key: { apikey: process.env.DAILY_API_KEY }, + userId: +new Date().getTime(), + }; + if (hasDailyIntegration) { + this.videoCredentials.push(dailyCredential); + } } /** @@ -91,13 +108,25 @@ export default class EventManager { ); const referencesToCreate: Array = results.map((result: EventResult) => { - return { - type: result.type, - uid: result.createdEvent.id.toString(), - meetingId: result.videoCallData?.id.toString(), - meetingPassword: result.videoCallData?.password, - meetingUrl: result.videoCallData?.url, - }; + const isDailyResult = result.type === "daily"; + if (isDailyResult) { + return { + type: result.type, + uid: result.createdEvent.name.toString(), + meetingId: result.videoCallData?.id.toString(), + meetingPassword: result.videoCallData?.password, + meetingUrl: result.videoCallData?.url, + }; + } + if (!isDailyResult) { + return { + type: result.type, + uid: result.createdEvent.id.toString(), + meetingId: result.videoCallData?.id.toString(), + meetingPassword: result.videoCallData?.password, + meetingUrl: result.videoCallData?.url, + }; + } }); return { @@ -136,7 +165,8 @@ export default class EventManager { }, }); - const isDedicated = EventManager.isDedicatedIntegration(event.location); + const isDedicated = + EventManager.isDedicatedIntegration(event.location) || event.location === dailyLocation; let results: Array = []; let optionalVideoCallData: VideoCallData | undefined = undefined; @@ -198,6 +228,7 @@ export default class EventManager { * @param optionalVideoCallData * @private */ + private createAllCalendarEvents( event: CalendarEvent, noMail: boolean, @@ -215,8 +246,10 @@ export default class EventManager { * @param event * @private */ + private getVideoCredential(event: CalendarEvent): Credential | undefined { const integrationName = event.location.replace("integrations:", ""); + return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName)); } @@ -232,8 +265,12 @@ export default class EventManager { private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise { const credential = this.getVideoCredential(event); - if (credential) { + const isDaily = event.location === dailyLocation; + + if (credential && !isDaily) { return createMeeting(credential, event, maybeUid); + } else if (isDaily) { + return dailyCreateMeeting(credential, event, maybeUid); } else { return Promise.reject("No suitable credentials given for the requested integration name."); } @@ -271,8 +308,9 @@ export default class EventManager { */ private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { const credential = this.getVideoCredential(event); + const isDaily = event.location === dailyLocation; - if (credential) { + if (credential && !isDaily) { const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0]; return updateMeeting(credential, bookingRef.uid, event).then((returnVal: EventResult) => { @@ -283,6 +321,10 @@ export default class EventManager { return returnVal; }); } else { + if (isDaily) { + const bookingRefUid = booking.references.filter((ref) => ref.type === "daily")[0].uid; + return dailyUpdateMeeting(credential, bookingRefUid, event); + } return Promise.reject("No suitable credentials given for the requested integration name."); } } @@ -300,7 +342,8 @@ export default class EventManager { */ private static isDedicatedIntegration(location: string): boolean { // Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't. - return location === "integrations:zoom"; + + return location === "integrations:zoom" || location === dailyLocation; } /** @@ -313,7 +356,11 @@ export default class EventManager { private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) { const location = locationObj.location; - if (location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf()) { + if ( + location === LocationType.GoogleMeet.valueOf() || + location === LocationType.Zoom.valueOf() || + location === LocationType.Daily.valueOf() + ) { const requestId = uuidv5(location, uuidv5.URL); return { diff --git a/lib/integrations.ts b/lib/integrations.ts index a088affd1e..9b317555d5 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -12,8 +12,8 @@ export function getIntegrationName(name: string) { return "Stripe"; case "apple_calendar": return "Apple Calendar"; - default: - return "Unknown"; + case "daily_video": + return "Daily"; } } diff --git a/lib/location.ts b/lib/location.ts index 3bd8f71fcf..40ed53ce99 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -3,4 +3,5 @@ export enum LocationType { Phone = "phone", GoogleMeet = "integrations:google:meet", Zoom = "integrations:zoom", + Daily = "integrations:daily", } diff --git a/package.json b/package.json index b54e9d485b..f003246f85 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@headlessui/react": "^1.4.1", + "@daily-co/daily-js": "^0.16.0", "@heroicons/react": "^1.0.4", "@hookform/resolvers": "^2.8.1", "@jitsu/sdk-js": "^2.2.4", @@ -84,6 +85,7 @@ "react-select": "^4.3.1", "react-timezone-select": "^1.0.7", "react-use-intercom": "1.4.0", + "react-router-dom": "^5.2.0", "short-uuid": "^4.2.0", "stripe": "^8.168.0", "superjson": "1.7.5", diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 89e03040ce..a95d2ba753 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -22,6 +22,13 @@ import { getBusyVideoTimes } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; +export interface DailyReturnType { + name: string; + url: string; + id: string; + created_at: string; +} + dayjs.extend(dayjsBusinessDays); dayjs.extend(utc); dayjs.extend(isBetween); @@ -249,7 +256,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const attendeesList = [...invitee, ...guests, ...teamMembers]; - const seed = `${users[0].username}:${dayjs(reqBody.start).utc().format()}`; + const seed = `${users[0].username}:${dayjs(req.body.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); const evt: CalendarEvent = { @@ -353,8 +360,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) selectedCalendars ); - const videoBusyTimes = await getBusyVideoTimes(credentials); + const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time); calendarBusyTimes.push(...videoBusyTimes); + console.log("calendarBusyTimes==>>>", calendarBusyTimes); const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({ start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), @@ -445,6 +453,46 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } + //for Daily.co video calls will grab the meeting token for the call + const isDaily = evt.location === "integrations:daily"; + + let dailyEvent: DailyReturnType; + + if (!rescheduleUid) { + dailyEvent = results.filter((ref) => ref.type === "daily")[0]?.createdEvent as DailyReturnType; + } else { + dailyEvent = results.filter((ref) => ref.type === "daily_video")[0]?.updatedEvent as DailyReturnType; + } + + let meetingToken; + if (isDaily) { + const response = await fetch("https://api.daily.co/v1/meeting-tokens", { + method: "POST", + body: JSON.stringify({ properties: { room_name: dailyEvent.name, is_owner: true } }), + headers: { + Authorization: "Bearer " + process.env.DAILY_API_KEY, + "Content-Type": "application/json", + }, + }); + meetingToken = await response.json(); + } + + //for Daily.co video calls will update the dailyEventReference table + + if (isDaily) { + await prisma.dailyEventReference.create({ + data: { + dailyurl: dailyEvent.url, + dailytoken: meetingToken.token, + booking: { + connect: { + uid: booking.uid, + }, + }, + }, + }); + } + if (eventType.requiresConfirmation && !rescheduleUid) { await new EventOrganizerRequestMail(evt, uid).sendEmail(); } diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 91a544e3c3..4cb2020cda 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -11,6 +11,8 @@ import { deleteMeeting } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; +import { dailyDeleteMeeting } from "../../lib/dailyVideoClient"; + export default async function handler(req, res) { // just bail if it not a DELETE if (req.method !== "DELETE" && req.method !== "POST") { @@ -37,6 +39,7 @@ export default async function handler(req, res) { }, }, attendees: true, + location: true, references: { select: { uid: true, @@ -118,6 +121,13 @@ export default async function handler(req, res) { return await deleteMeeting(credential, bookingRefUid); } } + //deleting a Daily meeting + + const isDaily = bookingToDelete.location === "integrations:daily"; + const bookingUID = bookingToDelete.references.filter((ref) => ref.type === "daily")[0]?.uid; + if (isDaily) { + return await dailyDeleteMeeting(credential, bookingUID); + } }); if (bookingToDelete && bookingToDelete.paid) { diff --git a/pages/call/[uid].tsx b/pages/call/[uid].tsx new file mode 100644 index 0000000000..b073311c5d --- /dev/null +++ b/pages/call/[uid].tsx @@ -0,0 +1,90 @@ +import DailyIframe from "@daily-co/daily-js"; +import { getSession } from "next-auth/client"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +import prisma from "../../lib/prisma"; + +export default function JoinCall(props, session) { + const router = useRouter(); + + //if no booking redirectis to the 404 page + const emptyBooking = props.booking === null; + useEffect(() => { + if (emptyBooking) { + router.push("/call/no-meeting-found"); + } + }); + + useEffect(() => { + if (!emptyBooking && session.userid !== props.booking.user.id) { + const callFrame = DailyIframe.createFrame({ + showLeaveButton: true, + iframeStyle: { + position: "fixed", + width: "100%", + height: "100%", + }, + }); + callFrame.join({ + url: props.booking.dailyRef.dailyurl, + showLeaveButton: true, + }); + } + if (!emptyBooking && session.userid === props.booking.user.id) { + const callFrame = DailyIframe.createFrame({ + showLeaveButton: true, + iframeStyle: { + position: "fixed", + width: "100%", + height: "100%", + }, + }); + callFrame.join({ + url: props.booking.dailyRef.dailyurl, + showLeaveButton: true, + token: props.booking.dailyRef.dailytoken, + }); + } + }, []); + + return JoinCall; +} + +export async function getServerSideProps(context) { + const booking = await prisma.booking.findFirst({ + where: { + uid: context.query.uid, + }, + select: { + id: true, + user: { + select: { + credentials: true, + }, + }, + attendees: true, + dailyRef: { + select: { + dailyurl: true, + dailytoken: true, + }, + }, + references: { + select: { + uid: true, + type: true, + }, + }, + }, + }); + + const session = await getSession(); + + return { + props: { + booking: booking, + session: session, + }, + }; +} diff --git a/pages/call/no-meeting-found.tsx b/pages/call/no-meeting-found.tsx new file mode 100644 index 0000000000..4a1269037f --- /dev/null +++ b/pages/call/no-meeting-found.tsx @@ -0,0 +1,52 @@ +import { XIcon } from "@heroicons/react/outline"; +import { ArrowRightIcon } from "@heroicons/react/solid"; + +import { HeadSeo } from "@components/seo/head-seo"; +import Button from "@components/ui/Button"; + +export default function NoMeetingFound() { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index 218c17785e..450c2e2690 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -270,6 +270,8 @@ const EventTypePage = (props: inferSSRProps) => { return

Cal will provide a Google Meet location.

; case LocationType.Zoom: return

Cal will provide a Zoom meeting URL.

; + case LocationType.Daily: + return

Cal will provide a video meeting URL.

; } return null; }; @@ -327,14 +329,14 @@ const EventTypePage = (props: inferSSRProps) => { name="title" id="title" required - className="pl-0 w-full text-xl font-bold focus:text-black text-gray-500 hover:text-gray-700 bg-transparent border-none cursor-pointer focus:ring-0 focus:outline-none" + className="w-full pl-0 text-xl font-bold text-gray-500 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none" placeholder="Quick Chat" defaultValue={eventType.title} /> {editIcon && ( )} @@ -456,6 +458,51 @@ const EventTypePage = (props: inferSSRProps) => { Google Meet )} + {location.type === LocationType.Daily && ( +
+ + + + + + + + + + Daily.co Video +
+ )} {location.type === LocationType.Zoom && (
) => {
) => {
@@ -752,7 +799,7 @@ const EventTypePage = (props: inferSSRProps) => { 0 ? eventType.price / 100.0 : undefined } /> -
+
{new Intl.NumberFormat("en", { style: "currency", @@ -1179,6 +1226,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const locationOptions: OptionTypeBase[] = [ { value: LocationType.InPerson, label: "Link or In-person meeting" }, { value: LocationType.Phone, label: "Phone call" }, + { value: LocationType.Zoom, label: "Zoom Video", disabled: true }, ]; if (hasIntegration(integrations, "zoom_video")) { @@ -1188,6 +1236,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (hasIntegration(integrations, "google_calendar")) { locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); } + const hasDailyIntegration = process.env.DAILY_API_KEY; + if (hasDailyIntegration) { + locationOptions.push({ value: LocationType.Daily, label: "Daily.co Video" }); + } + const currency = (credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData) ?.default_currency || "usd"; diff --git a/prisma/migrations/20210908220336_add_daily_data_table/migration.sql b/prisma/migrations/20210908220336_add_daily_data_table/migration.sql new file mode 100644 index 0000000000..ed36c926f0 --- /dev/null +++ b/prisma/migrations/20210908220336_add_daily_data_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "DailyEventReference" ( + "id" SERIAL NOT NULL, + "dailyurl" TEXT NOT NULL DEFAULT E'dailycallurl', + "dailytoken" TEXT NOT NULL DEFAULT E'dailytoken', + "bookingId" INTEGER, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DailyEventReference_bookingId_unique" ON "DailyEventReference"("bookingId"); + +-- AddForeignKey +ALTER TABLE "DailyEventReference" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ff3aea7b2b..ffe55f7ccb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,6 +162,14 @@ enum BookingStatus { PENDING @map("pending") } +model DailyEventReference { + id Int @id @default(autoincrement()) + dailyurl String @default("dailycallurl") + dailytoken String @default("dailytoken") + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? +} + model Booking { id Int @id @default(autoincrement()) uid String @unique @@ -179,6 +187,8 @@ model Booking { attendees Attendee[] location String? + dailyRef DailyEventReference? + createdAt DateTime @default(now()) updatedAt DateTime? confirmed Boolean @default(true) diff --git a/yarn.lock b/yarn.lock index f005a4fe9f..592bb7e2ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -282,15 +282,17 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.5", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0": version "7.15.4" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" + integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw== dependencies: regenerator-runtime "^0.13.4" "@babel/template@^7.12.13", "@babel/template@^7.15.4", "@babel/template@^7.3.3": version "7.15.4" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.15.4.tgz" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.15.4.tgz#51898d35dcf3faa670c4ee6afcfd517ee139f194" + integrity sha512-UgBAfEa1oGuYgDIPM2G+aHa4Nlo9Lh6mGD2bDBGMTbYnc38vulXPuC1MGjYILIEmlwl6Rd+BPR9ee3gm20CBtg== dependencies: "@babel/code-frame" "^7.14.5" "@babel/parser" "^7.15.4" @@ -391,6 +393,17 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@daily-co/daily-js@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.16.0.tgz#9020104bb88de62dcc1966e713da65844243b9ab" + integrity sha512-DBWzbZs2IR7uYqfbABva1Ms3f/oX85dnQnCpVbGbexTN63LPIGknFSQp31ZYED88qcG+YJNydywBTb+ApNiNXA== + dependencies: + "@babel/runtime" "^7.12.5" + bowser "^2.8.1" + events "^3.1.0" + fast-equals "^1.6.3" + lodash "^4.17.15" + "@emotion/cache@^11.4.0": version "11.4.0" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.4.0.tgz" @@ -2478,6 +2491,11 @@ bn.js@^5.0.0, bn.js@^5.1.1: version "5.2.0" resolved "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz" +bowser@^2.8.1: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" @@ -3703,7 +3721,7 @@ eventemitter2@^6.4.3: version "6.4.4" resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz" -events@^3.0.0: +events@^3.0.0, events@^3.1.0: version "3.3.0" resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" @@ -3811,6 +3829,11 @@ fast-diff@^1.1.2: version "1.2.0" resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz" +fast-equals@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-1.6.3.tgz#84839a1ce20627c463e1892f2ae316380c81b459" + integrity sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ== + fast-glob@^3.1.1, fast-glob@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz" @@ -4359,6 +4382,18 @@ highlight.js@^10.7.1: version "10.7.3" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz" +history@^4.9.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz" @@ -4367,7 +4402,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.2.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.2.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" dependencies: @@ -4826,6 +4861,11 @@ is-windows@^1.0.2: resolved "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" @@ -5795,9 +5835,10 @@ lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz" -lodash@4.17.21, lodash@4.x, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@4.17.21, lodash@4.x, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^4.0.0, log-symbols@^4.1.0: version "4.1.0" @@ -5815,7 +5856,7 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" dependencies: @@ -5949,6 +5990,14 @@ min-document@^2.19.0: dependencies: dom-walk "^0.1.0" +mini-create-react-context@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e" + integrity sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ== + dependencies: + "@babel/runtime" "^7.12.1" + tiny-warning "^1.0.3" + mini-svg-data-uri@^1.2.3: version "1.3.3" resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.3.3.tgz" @@ -6613,6 +6662,13 @@ path-parse@^1.0.6: version "1.0.7" resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -7140,7 +7196,7 @@ react-is@17.0.2, react-is@^17.0.1: version "17.0.2" resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz" -react-is@^16.7.0, react-is@^16.8.1: +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -7188,6 +7244,35 @@ react-remove-scroll@^2.4.0: use-callback-ref "^1.2.3" use-sidecar "^1.0.1" +react-router-dom@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.0.tgz#da1bfb535a0e89a712a93b97dd76f47ad1f32363" + integrity sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ== + dependencies: + "@babel/runtime" "^7.12.13" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.2.1" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d" + integrity sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ== + dependencies: + "@babel/runtime" "^7.12.13" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.4.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + react-select@^4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/react-select/-/react-select-4.3.1.tgz" @@ -7371,6 +7456,11 @@ resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve@^1.10.0, resolve@^1.20.0: version "1.20.0" resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz" @@ -8063,6 +8153,16 @@ timm@^1.6.1: resolved "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz#96bab60c7d45b5a10a8a4d0f0117c6b7e5aff76f" integrity sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw== +tiny-invariant@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + +tiny-warning@^1.0.0, tiny-warning@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tinycolor2@^1.4.1: version "1.4.2" resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" @@ -8430,6 +8530,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + verror@1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz"