Files
cal.diy/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts
T
2e2a6c73b1 feat: add advanced tab attributes to api/v2/event-types (#16314)
* added bookerLayouts

* added requiresConfirmation

* added tests

* undo extra changes

* undo extra change

* fixed imports

* added requiresBookerEmailVerification

* added hideCalendarNotes

* added lockTimeZoneToggleOnBookingPage

* added eventTypeColor

* undo extra changes

* added seats

* added requiresConfirmationWillBlockSlot to requiresConfirmation

* refactor: Implement BaseEventType class to avoid duplication across EventType input/output classes

## D-R-Y ##

* temp-fix: will revert later

* Correct placement of users array from EventTypeOutput to TeamEventTypeOutput

* Refactor: Shifted transformation logic to controllers using interceptors and pipes for a cleaner service layer.

* small fix

* Refactor: Moved transformation logic from service to controller using interceptors and pipes

** organisations/event-types

* feat: adde tests for `validateEventTypeInputs`

* fix: failing test

* fix: layout validator

* renamed blockCalendarForUnconfirmedBookings to blockUnconfirmedBookingsInBooker

* testing changes

* added disabled state

* fix merege conflict

* renamed requiresConfirmation to confirmationPolicy

* renamed OutputEventTypesResponseInterceptor to OutputTeamEventTypesResponseInterceptor

* renamed `darkThemeColor` -> `darkThemeHex` and `lightThemeColor` -> `lightThemeHex`

* renamed `eventTypeColor` -> `color`

* changed `requiresConfirmation` -> `confirmationPolicy`

* added tests for disabled state

* added class-validators to `disabled?: false`

* Revert: Split create and update input/output classes

* feat: Refactor responses to use DTOs with plainToClass for explicit property control

	•	Updated all endpoints to return response DTOs (e.g., CreateTeamEventTypeOutput, GetTeamEventTypeOutput) to ensure consistent Swagger documentation generation.
	•	Introduced plainToClass for transforming responses, enabling explicit control over which properties are returned in the response, following Morgan’s recommendation.
	•	Replaced generic HandlerResponse type with class-validator based DTOs for each endpoint, ensuring TypeScript enforces correct response structure.
	•	Applied strategy: "excludeAll" to limit response properties to only those explicitly defined in the DTO, preventing unintended fields from being included in the API responses.

* added pipes to transform output data

* add clean script to platform-types package.json

* added customName

* added destination calendar

* fixed type errors

* added useDestinationCalendarEmail

* refactor: api-reqest and api-response

* Update event-type.output.ts

* fixed errors

* Update yarn.lock

* added some missed properties to output

* Update constants.ts

* removed return type

* fixed some type errors

* reuse types

* Improve readability of validation functions

* Update CHANGELOG.md

* chore: post publish platform libraries

* Update documentation.json

* fix: reset platform libraries to 0.0.0

* fixup! fix: reset platform libraries to 0.0.0

* Update event-type.tranformed.ts

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-09-24 13:36:05 +03:00

354 lines
12 KiB
TypeScript

import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
import { metadata } from "../_metadata";
import { getZoomAppKeys } from "./getZoomAppKeys";
const log = logger.getSubLogger({ prefix: ["app-store/zoomvideo/lib/VideoApiAdapter"] });
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
const zoomEventResultSchema = z.object({
id: z.number(),
join_url: z.string(),
password: z.string().optional().default(""),
});
export type ZoomEventResult = z.infer<typeof zoomEventResultSchema>;
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/meetings */
export const zoomMeetingsSchema = z.object({
next_page_token: z.string(),
page_count: z.number(),
page_number: z.number(),
page_size: z.number(),
total_records: z.number(),
meetings: z.array(
z.object({
agenda: z.string(),
created_at: z.string(),
duration: z.number(),
host_id: z.string(),
id: z.number(),
join_url: z.string(),
pmi: z.string(),
start_time: z.string(),
timezone: z.string(),
topic: z.string(),
type: z.number(),
uuid: z.string(),
})
),
});
export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
export const zoomUserSettingsSchema = z.object({
recording: z.object({
auto_recording: z.string(),
}),
schedule_meeting: z.object({
default_password_for_scheduled_meetings: z.string(),
}),
});
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
// append comma seperated settings here, to retrieve only these specific settings
const settingsApiFilterResp = "default_password_for_scheduled_meetings,auto_recording";
type ZoomRecurrence = {
end_date_time?: string;
type: 1 | 2 | 3;
end_times?: number;
repeat_interval?: number;
weekly_days?: number; // 1-7 Sunday = 1, Saturday = 7
monthly_day?: number; // 1-31
};
const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const tokenResponse = getTokenObjectFromCredential(credential);
const getUserSettings = async () => {
let userSettings: ZoomUserSettings | undefined;
try {
const responseBody = await fetchZoomApi(
`users/me/settings?custom_query_fields=${settingsApiFilterResp}`
);
userSettings = zoomUserSettingsSchema.parse(responseBody);
} catch (err) {
log.error("Failed to retrieve zoom user settings", safeStringify(err));
}
return userSettings;
};
const translateEvent = async (event: CalendarEvent) => {
const getRecurrence = ({
recurringEvent,
startTime,
attendees,
}: CalendarEvent): { recurrence: ZoomRecurrence; type: 8 } | undefined => {
if (!recurringEvent) {
return;
}
let recurrence: ZoomRecurrence;
switch (recurringEvent.freq) {
case Frequency.DAILY:
recurrence = {
type: 1,
};
break;
case Frequency.WEEKLY:
recurrence = {
type: 2,
weekly_days: dayjs(startTime).tz(attendees[0].timeZone).day() + 1,
};
break;
case Frequency.MONTHLY:
recurrence = {
type: 3,
monthly_day: dayjs(startTime).tz(attendees[0].timeZone).date(),
};
break;
default:
// Zoom does not support YEARLY, HOURLY or MINUTELY frequencies, don't do anything in those cases.
return;
}
recurrence.repeat_interval = recurringEvent.interval;
if (recurringEvent.until) {
recurrence.end_date_time = recurringEvent.until.toISOString();
} else {
recurrence.end_times = recurringEvent.count;
}
return {
recurrence: {
...recurrence,
},
type: 8,
};
};
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
topic: event.title,
type: 2, // Means that this is a scheduled meeting
start_time: dayjs(event.startTime).utc().format(),
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.organizer.timeZone,
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
agenda: event.description,
settings: {
host_video: true,
participant_video: true,
cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India
join_before_host: true,
mute_upon_entry: false,
watermark: false,
use_pmi: false,
approval_type: 2,
audio: "both",
auto_recording: userSettings?.recording?.auto_recording || "none",
enforce_login: false,
registrants_email_notification: true,
},
...recurrence,
};
};
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
const auth = new OAuthManager({
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
if (!refreshToken) {
return null;
}
const clientCredentials = await getZoomAppKeys();
const { client_id, client_secret } = clientCredentials;
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
return fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
},
isTokenObjectUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.debug(safeStringify({ responseBody }));
if (responseBody.error === "invalid_grant") {
return { reason: responseBody.error };
}
}
return null;
},
isAccessTokenUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.debug(safeStringify({ responseBody }));
if (responseBody.code === 124) {
return { reason: responseBody.message ?? "" };
}
}
return null;
},
invalidateTokenObject: () => invalidateCredential(credential.id),
expireAccessToken: () => markTokenAsExpired(credential),
updateTokenObject: async (newTokenObject) => {
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: newTokenObject,
},
});
},
});
const { json } = await auth.request({
url: `https://api.zoom.us/v2/${endpoint}`,
options: {
method: "GET",
...options,
headers: {
...options?.headers,
},
},
});
return json;
};
return {
getAvailability: async () => {
try {
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
const responseBody = await fetchZoomApi("users/me/meetings?type=scheduled&page_size=300");
const data = zoomMeetingsSchema.parse(responseBody);
return data.meetings.map((meeting) => ({
start: meeting.start_time,
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
}));
} catch (err) {
console.error(err);
/* Prevents booking failure when Zoom Token is expired */
return [];
}
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
try {
const response = await fetchZoomApi("users/me/meetings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(await translateEvent(event)),
});
const result = zoomEventResultSchema.parse(response);
if (result.id && result.join_url) {
return {
type: "zoom_video",
id: result.id.toString(),
password: result.password || "",
url: result.join_url,
};
}
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
} catch (err) {
console.error(err);
/* Prevents meeting creation failure when Zoom Token is expired */
throw new Error("Unexpected error");
}
},
deleteMeeting: async (uid: string): Promise<void> => {
try {
await fetchZoomApi(`meetings/${uid}`, {
method: "DELETE",
});
return Promise.resolve();
} catch (err) {
return Promise.reject(new Error("Failed to delete meeting"));
}
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => {
try {
await fetchZoomApi(`meetings/${bookingRef.uid}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(await translateEvent(event)),
});
const updatedMeeting = await fetchZoomApi(`meetings/${bookingRef.uid}`);
const result = zoomEventResultSchema.parse(updatedMeeting);
return {
type: "zoom_video",
id: result.id.toString(),
password: result.password || "",
url: result.join_url,
};
} catch (err) {
log.error("Failed to update meeting", safeStringify(err));
return Promise.reject(new Error("Failed to update meeting"));
}
},
};
};
export default ZoomVideoApiAdapter;