From 7c9012738a18bce71ef502609f6fdd5fb3e1e25b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 14 Apr 2023 00:37:10 +0530 Subject: [PATCH] feat: add isRecordingExist field and create api handler (#6777) --- .../components/booking/BookingListItem.tsx | 3 +- apps/web/pages/api/recorded-daily-video.ts | 147 ++++++++++++++++++ apps/web/pages/video/[uid].tsx | 29 ++++ apps/web/public/emails/logo.png | Bin 0 -> 1036 bytes apps/web/public/static/locales/en/common.json | 6 + packages/app-store/apps.browser.generated.tsx | 1 - .../app-store/apps.keys-schemas.generated.ts | 1 - packages/app-store/apps.metadata.generated.ts | 1 - packages/app-store/apps.schemas.generated.ts | 1 - .../dailyvideo/lib/VideoApiAdapter.ts | 2 +- packages/emails/email-manager.ts | 12 ++ .../emails/src/components/EmailBodyLogo.tsx | 6 +- ...tendeeDailyVideoDownloadRecordingEmail.tsx | 98 ++++++++++++ packages/emails/src/templates/index.ts | 1 + ...ee-daily-video-download-recording-email.ts | 59 +++++++ packages/emails/templates/confirm-email.html | 2 +- packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 9 ++ .../trpc/server/routers/viewer/bookings.tsx | 3 +- 21 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 apps/web/pages/api/recorded-daily-video.ts create mode 100644 apps/web/public/emails/logo.png create mode 100644 packages/emails/src/templates/AttendeeDailyVideoDownloadRecordingEmail.tsx create mode 100644 packages/emails/templates/attendee-daily-video-download-recording-email.ts create mode 100644 packages/prisma/migrations/20230129094557_add_recording_exist/migration.sql diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index c8773abb61..04b86a9132 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -256,10 +256,9 @@ function BookingListItem(booking: BookingItemProps) { }, }); }; - const showRecordingsButtons = - (booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed; const title = booking.title; + const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed; return ( <> { + return { + id: attendee.id, + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const isUserAttendeeOrOrganiser = + booking?.user?.id === session.user.id || + attendeesList.find((attendee) => attendee.id === session.user.id); + + if (!isUserAttendeeOrOrganiser) { + return res.status(403).send({ + message: "Unauthorised", + }); + } + + await prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + isRecorded: true, + }, + }); + + const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam; + + // send emails to all attendees only when user has team plan + if (isSendingEmailsAllowed) { + const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + + const downloadLinkResponse = downloadLinkSchema.parse(response); + const downloadLink = downloadLinkResponse.download_link; + + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description || undefined, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: booking.user?.email || "Email-less", + name: booking.user?.name || "Nameless", + timeZone: booking.user?.timeZone || "Europe/London", + language: { translate: t, locale: booking?.user?.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + }; + + await sendDailyVideoRecordingEmails(evt, downloadLink); + return res.status(200).json({ message: "Success" }); + } + + return res.status(403).json({ message: "User does not have team plan to send out emails" }); + } catch (err) { + console.warn("something_went_wrong", err); + return res.status(500).json({ message: "something went wrong" }); + } +} + +export default defaultHandler({ + POST: Promise.resolve({ default: handler }), +}); diff --git a/apps/web/pages/video/[uid].tsx b/apps/web/pages/video/[uid].tsx index 422df6e54f..fd6cfc43e5 100644 --- a/apps/web/pages/video/[uid].tsx +++ b/apps/web/pages/video/[uid].tsx @@ -1,8 +1,10 @@ +import type { DailyEventObjectRecordingStarted } from "@daily-co/daily-js"; import DailyIframe from "@daily-co/daily-js"; import MarkdownIt from "markdown-it"; import type { GetServerSidePropsContext } from "next"; import Head from "next/head"; import { useState, useEffect, useRef } from "react"; +import z from "zod"; import dayjs from "@calcom/dayjs"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; @@ -16,12 +18,19 @@ import { ChevronRight } from "@calcom/ui/components/icon"; import { ssrInit } from "@server/lib/ssr"; +const recordingStartedEventResponse = z + .object({ + recordingId: z.string(), + }) + .passthrough(); + export type JoinCallPageProps = inferSSRProps; const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); export default function JoinCall(props: JoinCallPageProps) { const { t } = useLocale(); const { meetingUrl, meetingPassword, booking } = props; + const recordingId = useRef(null); useEffect(() => { const callFrame = DailyIframe.createFrame({ @@ -49,11 +58,30 @@ export default function JoinCall(props: JoinCallPageProps) { ...(typeof meetingPassword === "string" && { token: meetingPassword }), }); callFrame.join(); + callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped); return () => { callFrame.destroy(); }; }, []); + const onRecordingStopped = () => { + const data = { recordingId: recordingId.current, bookingUID: booking.uid }; + + fetch("/api/recorded-daily-video", { + method: "POST", + body: JSON.stringify(data), + }).catch((err) => { + console.log(err); + }); + + recordingId.current = null; + }; + + const onRecordingStarted = (event?: DailyEventObjectRecordingStarted | undefined) => { + const response = recordingStartedEventResponse.parse(event); + recordingId.current = response.recordingId; + }; + const title = `${APP_NAME} Video`; return ( <> @@ -246,6 +274,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ...bookingMinimalSelect, uid: true, description: true, + isRecorded: true, user: { select: { id: true, diff --git a/apps/web/public/emails/logo.png b/apps/web/public/emails/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d22c77e2e1141cd68cb7f46730e3bbd50f347107 GIT binary patch literal 1036 zcmV+n1oQieP)C@3Q%BPb{+Dk>@{C@3f>C@Lx{C@3f?DJdl-B`GN>DJdx_DJdo< zCMYN@|DJdx_DSoCE(EtDd7IachQvko;U?86mK<|KXfRN8HuTbw$pfDfqYSah- z00Ur2L_t(o!{wOUlG`8*h7FPhK08j<@BgTsFeCyA>~%YxrnCRW&c-108H9vsgb3#u zn{UhPPnqUl$qmNuLDK^GA0V%0&(z|FBR|Y8Ww(z;?)Q?04@U0xvigrjW@4d`wZoe-F|H`3Ob)B44R#DbdIb|w9q*>*MVjL*phZ~0FVyk zDsh|rgji%Icya~}$H;E9aqU`{4ZFVTmC4GL4eA>je6)i&(5wG4i%Ir)P!?^Ht+9!L zeuRuB+r>O~@6(2n=VcG-a7taJsevPv&U5gXR~W1!OEB%s$Z~}O+2*ZG%s!hn{=$=3 zXPGl(_9L93RnKp8hAcYRhbk02h0CR+t&z2bo=##Q&Afk0HwsRV>m0IIW2Qr7ZLuuT z!ce+gelo_tJ5i2odVsif`N2Jnu-NEo$o6IE0#5HM$l9WvgT>R^n5HJ*Q?{pv3>i2Y znT%uV#$CT^$s!wSQ`a zOh&fq&EMT)Y^;hO>;}O0D(?8N?I_ys1>Az8G0~k%e(w7{?Ja+Zpcw0000 import("./exchange2013calendar/components/InstallAppButton")), exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 403584009b..b22123b2d0 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -25,7 +25,6 @@ import { appKeysSchema as vital_zod_ts } from "./vital/zod"; import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod"; import { appKeysSchema as zapier_zod_ts } from "./zapier/zod"; import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; - export const appKeysSchemas = { dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index f0da2045b3..7ddf76a476 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -61,7 +61,6 @@ import wordpress_config_json from "./wordpress/config.json"; import { metadata as zapier__metadata_ts } from "./zapier/_metadata"; import zohocrm_config_json from "./zohocrm/config.json"; import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; - export const appStoreMetadata = { amie: amie_config_json, applecalendar: applecalendar__metadata_ts, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index d9d55a918a..c4a555de90 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -25,7 +25,6 @@ import { appDataSchema as vital_zod_ts } from "./vital/zod"; import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod"; import { appDataSchema as zapier_zod_ts } from "./zapier/zod"; import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; - export const appDataSchemas = { dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 87250e59eb..6e7dbe2d63 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -161,7 +161,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { }, getRecordingDownloadLink: async (recordingId: string): Promise => { try { - const res = await fetcher(`/recordings/${recordingId}/access-link`).then( + const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=172800`).then( getAccessLinkResponseSchema.parse ); return Promise.resolve(res); diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 782c7bae97..8030c316af 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -9,6 +9,7 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email"; import AttendeeCancelledEmail from "./templates/attendee-cancelled-email"; import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email"; +import AttendeeDailyVideoDownloadRecordingEmail from "./templates/attendee-daily-video-download-recording-email"; import AttendeeDeclinedEmail from "./templates/attendee-declined-email"; import AttendeeLocationChangeEmail from "./templates/attendee-location-change-email"; import AttendeeRequestEmail from "./templates/attendee-request-email"; @@ -307,3 +308,14 @@ export const sendSlugReplacementEmail = async ({ export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => { await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee)); }; + +export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, downloadLink: string) => { + const emailsToSend: Promise[] = []; + + for (const attendee of calEvent.attendees) { + emailsToSend.push( + sendEmail(() => new AttendeeDailyVideoDownloadRecordingEmail(calEvent, attendee, downloadLink)) + ); + } + await Promise.all(emailsToSend); +}; diff --git a/packages/emails/src/components/EmailBodyLogo.tsx b/packages/emails/src/components/EmailBodyLogo.tsx index 0da903fb41..23ddc92359 100644 --- a/packages/emails/src/components/EmailBodyLogo.tsx +++ b/packages/emails/src/components/EmailBodyLogo.tsx @@ -1,4 +1,4 @@ -import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import RawHtml from "./RawHtml"; import Row from "./Row"; @@ -6,9 +6,7 @@ import Row from "./Row"; const CommentIE = ({ html = "" }) => ${html}`} />; const EmailBodyLogo = () => { - const image = IS_PRODUCTION - ? WEBAPP_URL + "/emails/CalLogo@2x.png" - : "https://app.cal.com/emails/CalLogo@2x.png"; + const image = WEBAPP_URL + "/emails/logo.png"; return ( <> diff --git a/packages/emails/src/templates/AttendeeDailyVideoDownloadRecordingEmail.tsx b/packages/emails/src/templates/AttendeeDailyVideoDownloadRecordingEmail.tsx new file mode 100644 index 0000000000..6cdcb68b20 --- /dev/null +++ b/packages/emails/src/templates/AttendeeDailyVideoDownloadRecordingEmail.tsx @@ -0,0 +1,98 @@ +import type { TFunction } from "next-i18next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { V2BaseEmailHtml, CallToAction } from "../components"; + +interface AttendeeDailyVideoDownloadRecordingEmailProps { + language: TFunction; + downloadLink: string; + title: string; + date: string; + name: string; +} + +export const AttendeeDailyVideoDownloadRecordingEmail = ( + props: AttendeeDailyVideoDownloadRecordingEmailProps & Partial> +) => { + const image = WEBAPP_URL + "/emails/logo.png"; + return ( + +
+ + + +
+

+ <>{props.language("download_your_recording")} +

+

+ <>{props.language("hi_user_name", { name: props.name })}, +

+

+ <>{props.language("recording_from_your_recent_call")} +

+ +
+

+ <>{props.title} +

+

+ {props.date} +

+ +
+ +

+ <>{props.language("happy_scheduling")}, +

+

+ <>{props.language("the_calcom_team")} +

+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index db7f28fa6a..badde58cc6 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -24,3 +24,4 @@ export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail"; export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail"; export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail"; export * from "@calcom/app-store/routing-forms/emails/components"; +export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail"; diff --git a/packages/emails/templates/attendee-daily-video-download-recording-email.ts b/packages/emails/templates/attendee-daily-video-download-recording-email.ts new file mode 100644 index 0000000000..e878f0b705 --- /dev/null +++ b/packages/emails/templates/attendee-daily-video-download-recording-email.ts @@ -0,0 +1,59 @@ +// TODO: We should find a way to keep App specific email templates within the App itself +import type { TFunction } from "next-i18next"; + +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { renderEmail } from "../"; +import BaseEmail from "./_base-email"; + +export default class AttendeeDailyVideoDownloadRecordingEmail extends BaseEmail { + calEvent: CalendarEvent; + attendee: Person; + downloadLink: string; + t: TFunction; + + constructor(calEvent: CalendarEvent, attendee: Person, downloadLink: string) { + super(); + this.name = "SEND_RECORDING_DOWNLOAD_LINK"; + this.calEvent = calEvent; + this.attendee = attendee; + this.downloadLink = downloadLink; + this.t = attendee.language.translate; + } + protected getNodeMailerPayload(): Record { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], + subject: `${this.t("download_recording_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: renderEmail("AttendeeDailyVideoDownloadRecordingEmail", { + title: this.calEvent.title, + date: this.getFormattedDate(), + downloadLink: this.downloadLink, + language: this.t, + name: this.attendee.name, + }), + }; + } + + protected getTimezone(): string { + return this.attendee.timeZone; + } + + protected getInviteeStart(format: string) { + return this.getRecipientTime(this.calEvent.startTime, format); + } + + protected getInviteeEnd(format: string) { + return this.getRecipientTime(this.calEvent.endTime, format); + } + + protected getFormattedDate() { + return `${this.getInviteeStart("h:mma")} - ${this.getInviteeEnd("h:mma")}, ${this.t( + this.getInviteeStart("dddd").toLowerCase() + )}, ${this.t(this.getInviteeStart("MMMM").toLowerCase())} ${this.getInviteeStart("D, YYYY")}`; + } +} diff --git a/packages/emails/templates/confirm-email.html b/packages/emails/templates/confirm-email.html index 11ee909a4c..fbb80b1838 100644 --- a/packages/emails/templates/confirm-email.html +++ b/packages/emails/templates/confirm-email.html @@ -474,7 +474,7 @@ ; +export const sendDailyVideoRecordingEmailsSchema = z.object({ + recordingId: z.string(), + bookingUID: z.string(), +}); + +export const downloadLinkSchema = z.object({ + download_link: z.string(), +}); + // All properties within event type that can and will be updated if needed export const allManagedEventTypeProps: { [k in keyof Omit]: true } = { title: true, diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index 942568f796..503ad557b4 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -1,5 +1,5 @@ -import type { BookingReference, EventType, User, WebhookTriggerEvents } from "@prisma/client"; import { BookingStatus, MembershipRole, Prisma, SchedulingType, WorkflowMethods } from "@prisma/client"; +import type { BookingReference, EventType, User, WebhookTriggerEvents } from "@prisma/client"; import type { TFunction } from "next-i18next"; import { z } from "zod"; @@ -285,6 +285,7 @@ export const bookingsRouter = router({ }, rescheduled: true, references: true, + isRecorded: true, seatsReferences: { where: { attendee: {