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 0000000000..d22c77e2e1 Binary files /dev/null and b/apps/web/public/emails/logo.png differ diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a3f96b2e5a..8f32bb29bf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -69,6 +69,8 @@ "event_awaiting_approval_subject": "Awaiting Approval: {{title}} at {{date}}", "event_still_awaiting_approval": "An event is still waiting for your approval", "booking_submitted_subject": "Booking Submitted: {{title}} at {{date}}", + "download_recording_subject": "Download Recording: {{title}} at {{date}}", + "download_your_recording": "Download your recording", "your_meeting_has_been_booked": "Your meeting has been booked", "event_type_has_been_rescheduled_on_time_date": "Your {{title}} has been rescheduled to {{date}}.", "event_has_been_rescheduled": "Updated - Your event has been rescheduled", @@ -1199,6 +1201,7 @@ "start_of_week": "Start of week", "recordings_title": "Recordings", "recording": "Recording", + "happy_scheduling": "Happy Scheduling", "select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.", "check_for_conflicts": "Check for conflicts", "view_recordings": "View recordings", @@ -1277,6 +1280,8 @@ "download_responses": "Download responses", "download_responses_description": "Download all responses to your form in CSV format.", "download": "Download", + "download_recording": "Download Recording", + "recording_from_your_recent_call":"A recording from your recent call on Cal.com is ready for download", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", "create_your_first_webhook": "Create your first Webhook", @@ -1465,6 +1470,7 @@ "fixed_round_robin": "Fixed round robin", "add_one_fixed_attendee": "Add one fixed attendee and round robin through a number of attendees.", "calcom_is_better_with_team": "Cal.com is better with teams", + "the_calcom_team":"The Cal.com team", "add_your_team_members": "Add your team members to your event types. Use collective scheduling to include everyone or find the most suitable person with round robin scheduling.", "booking_limit_reached": "Booking Limit for this event type has been reached", "duration_limit_reached": "Duration Limit for this event type has been reached", diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index d483e35122..356dde6f20 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -3,7 +3,6 @@ Don't modify this file manually. **/ import dynamic from "next/dynamic"; - export const InstallAppButtonMap = { exchange2013calendar: dynamic(() => 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: {