feat: add isRecordingExist field and create api handler (#6777)

This commit is contained in:
Udit Takkar 2023-04-14 00:37:10 +05:30 committed by GitHub
parent 5c763389f7
commit 7c9012738a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 372 additions and 13 deletions

View File

@ -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 (
<>
<RescheduleDialog

View File

@ -0,0 +1,147 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
const schema = z.object({
recordingId: z.string(),
bookingUID: z.string(),
});
const downloadLinkSchema = z.object({
download_link: z.string(),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
}
const response = schema.safeParse(JSON.parse(req.body));
if (!response.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { recordingId, bookingUID } = response.data;
const session = await getServerSession({ req, res });
if (!session?.user) {
return res.status(401).send({
message: "User not logged in",
});
}
try {
const booking = await prisma.booking.findFirst({
where: {
uid: bookingUID,
},
select: {
...bookingMinimalSelect,
uid: true,
location: true,
isRecorded: true,
user: {
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking || booking.location !== DailyLocationType) {
return res.status(404).send({
message: `Booking of uid ${bookingUID} does not exist or does not contain daily video as location`,
});
}
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
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 }),
});

View File

@ -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<typeof getServerSideProps>;
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<string | null>(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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -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",

View File

@ -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")),

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -161,7 +161,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
},
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
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);

View File

@ -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<unknown>[] = [];
for (const attendee of calEvent.attendees) {
emailsToSend.push(
sendEmail(() => new AttendeeDailyVideoDownloadRecordingEmail(calEvent, attendee, downloadLink))
);
}
await Promise.all(emailsToSend);
};

View File

@ -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 = "" }) => <RawHtml html={`<!--[if mso | IE]>${html}<![endif]-->`} />;
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 (
<>

View File

@ -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<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const image = WEBAPP_URL + "/emails/logo.png";
return (
<V2BaseEmailHtml
subject={props.language("download_your_recording", {
title: props.title,
date: props.date,
})}>
<div style={{ width: "89px", marginBottom: "35px" }}>
<a href={WEBAPP_URL} target="_blank" rel="noreferrer">
<img
height="19"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "19px",
width: "100%",
fontSize: "13px",
}}
width="89"
alt=""
/>
</a>
</div>
<p
style={{
fontSize: "32px",
fontWeight: "600",
lineHeight: "38.5px",
marginBottom: "40px",
color: "black",
}}>
<>{props.language("download_your_recording")}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("hi_user_name", { name: props.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "40px" }}>
<>{props.language("recording_from_your_recent_call")}</>
</p>
<div
style={{
backgroundColor: "#F3F4F6",
padding: "32px",
marginBottom: "40px",
}}>
<p
style={{
fontSize: "18px",
lineHeight: "20px",
fontWeight: 600,
marginBottom: "8px",
color: "black",
}}>
<>{props.title}</>
</p>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "24px",
marginTop: "0px",
color: "black",
}}>
{props.date}
</p>
<CallToAction label={props.language("download_recording")} href={props.downloadLink} />
</div>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "32px", marginBottom: "8px" }}>
<>{props.language("happy_scheduling")},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "0px" }}>
<>{props.language("the_calcom_team")}</>
</p>
</V2BaseEmailHtml>
);
};

View File

@ -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";

View File

@ -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<string, unknown> {
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")}`;
}
}

View File

@ -474,7 +474,7 @@
<a href="{{base_url}}" target="_blank">
<img
height="19"
src="https://app.cal.com/emails/CalLogo@2x.png"
src="https://app.cal.com/emails/logo.png"
style="
border: 0;
display: block;

View File

@ -57,6 +57,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
scheduledJobs: [],
metadata: null,
responses: null,
isRecorded: false,
...booking,
};
};

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "isRecorded" BOOLEAN NOT NULL DEFAULT false;

View File

@ -335,6 +335,7 @@ model Booking {
seatsReferences BookingSeat[]
/// @zod.custom(imports.bookingMetadataSchema)
metadata Json?
isRecorded Boolean @default(false)
}
model Schedule {

View File

@ -450,6 +450,15 @@ export const getAccessLinkResponseSchema = z.object({
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;
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<Prisma.EventTypeSelect, "id">]: true } = {
title: true,

View File

@ -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: {