Merge branch 'main' into minimum-booking-notice-will-allow-hours-and-days

This commit is contained in:
Peer Richelsen 2022-10-31 22:07:00 +00:00 committed by GitHub
commit 03f243db21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 334 additions and 189 deletions

View File

@ -2,6 +2,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui";
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List";
import classNames from "@lib/classNames";
@ -17,7 +19,9 @@ function IntegrationListItem(props: {
logo: string;
destination?: boolean;
separate?: boolean;
invalidCredential?: boolean;
}): JSX.Element {
const { t } = useLocale();
const router = useRouter();
const { hl } = router.query;
const [highlight, setHighlight] = useState(hl === props.slug);
@ -44,6 +48,15 @@ function IntegrationListItem(props: {
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
{/* Alert error that key stopped working. */}
{props.invalidCredential && (
<div className="flex items-center space-x-2">
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
</div>
)}
</div>
<div>{props.actions}</div>
</div>

View File

@ -1,4 +1,3 @@
import { InferGetServerSidePropsType } from "next";
import { useRouter } from "next/router";
import z from "zod";
@ -10,7 +9,6 @@ import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { App } from "@calcom/types/App";
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
import { Icon } from "@calcom/ui/Icon";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { Alert } from "@calcom/ui/v2/core/Alert";
import Button from "@calcom/ui/v2/core/Button";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
@ -23,16 +21,19 @@ import { QueryCell } from "@lib/QueryCell";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import IntegrationListItem from "@components/apps/IntegrationListItem";
import SkeletonLoader from "@components/v2/availability/SkeletonLoader";
function ConnectOrDisconnectIntegrationButton(props: {
credentialIds: number[];
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
}) {
const { type, credentialIds, isGlobal, installed, invalidCredentialIds } = props;
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const [credentialId] = credentialIds;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
@ -49,6 +50,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
/>
);
}
return (
<DisconnectIntegration
credentialId={credentialId}
@ -58,7 +60,8 @@ function ConnectOrDisconnectIntegrationButton(props: {
/>
);
}
if (!props.installed) {
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
@ -66,7 +69,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
);
}
/** We don't need to "Connect", just show that it's installed */
if (props.isGlobal) {
if (isGlobal) {
return (
<div className="truncate px-3 py-2">
<h3 className="text-sm font-medium text-gray-700">{t("default")}</h3>
@ -75,7 +78,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
}
return (
<InstallAppButton
type={props.type}
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
@ -99,28 +102,32 @@ interface IntegrationsListProps {
const IntegrationsList = ({ data }: IntegrationsListProps) => {
return (
<List className="flex flex-col gap-6" noBorderTreatment>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
separate={true}
actions={
<div className="flex w-16 justify-end">
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
/>
</div>
}>
<AppSettings slug={item.slug} />
</IntegrationListItem>
))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
separate={true}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<div className="flex w-16 justify-end">
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
/>
</div>
}>
<AppSettings slug={item.slug} />
</IntegrationListItem>
))}
</List>
);
};

View File

@ -140,7 +140,7 @@
"slide_zoom_drag_instructions": "Slide to zoom, drag to reposition",
"view_notifications": "View notifications",
"view_public_page": "View public page",
"copy_public_page_link":"Copy public page link",
"copy_public_page_link": "Copy public page link",
"sign_out": "Sign out",
"add_another": "Add another",
"install_another": "Install another",
@ -1266,9 +1266,9 @@
"seats": "seats",
"every_app_published": "Every app published on the Cal.com App Store is open source and thoroughly tested via peer reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are published by Cal.com. If you encounter inappropriate content or behaviour please report it.",
"report_app": "Report app",
"limit_booking_frequency":"Limit booking frequency",
"limit_booking_frequency_description":"Limit how many times this event can be booked",
"add_limit":"Add Limit",
"limit_booking_frequency": "Limit booking frequency",
"limit_booking_frequency_description": "Limit how many times this event can be booked",
"add_limit": "Add Limit",
"team_name_required": "Team name required",
"show_attendees": "Share attendee information between guests",
"how_additional_inputs_as_variables": "How to use Additional Inputs as Variables",
@ -1332,11 +1332,12 @@
"saml_sp_entity_id_copied": "SP Entity ID copied!",
"saml_btn_configure": "Configure",
"add_calendar": "Add Calendar",
"limit_future_bookings":"Limit future bookings",
"limit_future_bookings_description":"Limit how far in the future this event can be booked",
"limit_future_bookings": "Limit future bookings",
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
"no_event_types": "No event types setup",
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
"attendee_email_workflow": "Attendee email",
"attendee_email_info": "The person booking's email"
"attendee_email_info": "The person booking's email",
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again."
}

View File

@ -1,13 +1,12 @@
import { Credential } from "@prisma/client";
import logger from "@calcom/lib/logger";
import type { Calendar } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import appStore from "..";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
export const getCalendar = (credential: Credential | null): Calendar | null => {
export const getCalendar = (credential: CredentialPayload | null): Calendar | null => {
if (!credential || !credential.key) return null;
const { type: calendarType } = credential;
const calendarApp = appStore[calendarType.split("_").join("") as keyof typeof appStore];

View File

@ -1,9 +1,8 @@
import { Credential } from "@prisma/client";
import CalendarService from "@calcom/lib/CalendarService";
import { CredentialPayload } from "@calcom/types/Credential";
export default class AppleCalendarService extends CalendarService {
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
super(credential, "apple_calendar", "https://caldav.icloud.com");
}
}

View File

@ -1,9 +1,8 @@
import { Credential } from "@prisma/client";
import CalendarService from "@calcom/lib/CalendarService";
import { CredentialPayload } from "@calcom/types/Credential";
export default class CalDavCalendarService extends CalendarService {
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
super(credential, "caldav_calendar");
}
}

View File

@ -1,4 +1,3 @@
import { Credential } from "@prisma/client";
import z from "zod";
import CloseCom, { CloseComFieldOptions } from "@calcom/lib/CloseCom";
@ -12,6 +11,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
const apiKeySchema = z.object({
encrypted: z.string(),
@ -55,7 +55,7 @@ export default class CloseComCalendarService implements Calendar {
private closeCom: CloseCom;
private log: typeof logger;
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "closecom_other_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });

View File

@ -1,8 +1,8 @@
import { Credential } from "@prisma/client";
import { z } from "zod";
import { handleErrorsJson } from "@calcom/lib/errors";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -53,12 +53,13 @@ const meetingTokenSchema = z.object({
});
/** @deprecated use metadata on index file */
export const FAKE_DAILY_CREDENTIAL: Credential = {
export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = {
id: +new Date().getTime(),
type: "daily_video",
key: { apikey: process.env.DAILY_API_KEY },
userId: +new Date().getTime(),
appId: "daily-video",
invalid: false,
};
const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {

View File

@ -1,4 +1,3 @@
import { Credential } from "@prisma/client";
import {
Appointment,
Attendee,
@ -32,6 +31,7 @@ import {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
export default class ExchangeCalendarService implements Calendar {
private url = "";
@ -40,7 +40,7 @@ export default class ExchangeCalendarService implements Calendar {
private readonly exchangeVersion: ExchangeVersion;
private credentials: Record<string, string>;
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "exchange2013_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });

View File

@ -1,4 +1,3 @@
import { Credential } from "@prisma/client";
import {
Appointment,
Attendee,
@ -32,6 +31,7 @@ import {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
export default class ExchangeCalendarService implements Calendar {
private url = "";
@ -40,7 +40,7 @@ export default class ExchangeCalendarService implements Calendar {
private readonly exchangeVersion: ExchangeVersion;
private credentials: Record<string, string>;
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
// this.integrationName = CALENDAR_INTEGRATIONS_TYPES.exchange;
this.integrationName = "exchange2016_calendar";

View File

@ -1,5 +1,4 @@
import { XhrApi } from "@ewsjs/xhr";
import { Credential } from "@prisma/client";
import {
Appointment,
Attendee,
@ -39,6 +38,7 @@ import {
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import { ExchangeAuthentication } from "../enums";
@ -47,7 +47,7 @@ export default class ExchangeCalendarService implements Calendar {
private log: typeof logger;
private payload;
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "exchange_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.payload = JSON.parse(

View File

@ -1,4 +1,4 @@
import { Credential, Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { calendar_v3, google } from "googleapis";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
@ -12,6 +12,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
import { googleCredentialSchema } from "./googleCredentialSchema";
@ -25,13 +26,13 @@ export default class GoogleCalendarService implements Calendar {
private auth: { getToken: () => Promise<MyGoogleAuth> };
private log: typeof logger;
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "google_calendar";
this.auth = this.googleAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private googleAuth = (credential: Credential) => {
private googleAuth = (credential: CredentialPayload) => {
const googleCredentials = googleCredentialSchema.parse(credential.key);
async function getGoogleAuth() {

View File

@ -2,7 +2,6 @@ import * as hubspot from "@hubspot/api-client";
import { BatchInputPublicAssociation } from "@hubspot/api-client/lib/codegen/crm/associations";
import { PublicObjectSearchRequest } from "@hubspot/api-client/lib/codegen/crm/contacts";
import { SimplePublicObjectInput } from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import { Credential } from "@prisma/client";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
@ -17,6 +16,7 @@ import type {
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import type { HubspotToken } from "../api/callback";
@ -31,7 +31,7 @@ export default class HubspotOtherCalendarService implements Calendar {
private client_id = "";
private client_secret = "";
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "hubspot_other_calendar";
this.auth = this.hubspotAuth(credential).then((r) => r);
@ -148,7 +148,7 @@ export default class HubspotOtherCalendarService implements Calendar {
return hubspotClient.crm.objects.meetings.basicApi.archive(uid);
};
private hubspotAuth = async (credential: Credential) => {
private hubspotAuth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("hubspot");
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") this.client_secret = appKeys.client_secret;

View File

@ -17,15 +17,17 @@ const Huddle01VideoApiAdapter = (): VideoApiAdapter => {
"https://wpss2zlpb9.execute-api.us-east-1.amazonaws.com/new-meeting?utmCampaign=cal.com&utmSource=partner&utmMedium=calendar"
);
const json = await handleErrorsJson(res);
const json = await handleErrorsJson<{ url: string }>(res);
const { url } = huddle01Schema.parse(json);
return Promise.resolve({
type: "huddle01_video",
id: randomString(21),
password: "",
url,
});
if (url) {
return Promise.resolve({
type: "huddle01_video",
id: randomString(21),
password: "",
url,
});
}
return Promise.reject("Url was not received in response body.");
},
deleteMeeting: async (): Promise<void> => {
Promise.resolve();

View File

@ -1,5 +1,3 @@
import { Credential } from "@prisma/client";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
@ -11,6 +9,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import { handleLarkError, isExpired, LARK_HOST } from "../common";
import type {
@ -36,13 +35,13 @@ export default class LarkCalendarService implements Calendar {
private log: typeof logger;
auth: { getToken: () => Promise<string> };
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "lark_calendar";
this.auth = this.larkAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private larkAuth = (credential: Credential) => {
private larkAuth = (credential: CredentialPayload) => {
const larkAuthCredentials = credential.key as LarkAuthCredentials;
return {
getToken: () =>
@ -52,7 +51,7 @@ export default class LarkCalendarService implements Calendar {
};
};
private refreshAccessToken = async (credential: Credential) => {
private refreshAccessToken = async (credential: CredentialPayload) => {
const larkAuthCredentials = credential.key as LarkAuthCredentials;
const refreshExpireDate = larkAuthCredentials.refresh_expires_date;
const refreshToken = larkAuthCredentials.refresh_token;

View File

@ -1,5 +1,4 @@
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential } from "@prisma/client";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
@ -13,6 +12,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import { O365AuthCredentials } from "../types/Office365Calendar";
import { getOfficeAppKeys } from "./getOfficeAppKeys";
@ -45,7 +45,7 @@ export default class Office365CalendarService implements Calendar {
auth: { getToken: () => Promise<string> };
private apiGraphUrl = "https://graph.microsoft.com/v1.0";
constructor(credential: Credential) {
constructor(credential: CredentialPayload) {
this.integrationName = "office365_calendar";
this.auth = this.o365Auth(credential);
@ -131,7 +131,7 @@ export default class Office365CalendarService implements Calendar {
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
const response = await this.apiGraphBatchCall(requests);
const responseBody = await handleErrorsJson(response);
const responseBody = await this.handleErrorJsonOffice365Calendar(response);
let responseBatchApi: IBatchResponse = { responses: [] };
if (typeof responseBody === "string") {
responseBatchApi = this.handleTextJsonResponseWithHtmlInBody(responseBody);
@ -158,12 +158,12 @@ export default class Office365CalendarService implements Calendar {
async listCalendars(): Promise<IntegrationCalendar[]> {
const response = await this.fetcher(`/me/calendars`);
let responseBody = await handleErrorsJson(response);
let responseBody = await handleErrorsJson<{ value: OfficeCalendar[] }>(response);
// If responseBody is valid then parse the JSON text
if (typeof responseBody === "string") {
responseBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
}
return responseBody.value.map((cal: OfficeCalendar) => {
return responseBody?.value.map((cal: OfficeCalendar) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: this.integrationName,
@ -175,7 +175,7 @@ export default class Office365CalendarService implements Calendar {
});
}
private o365Auth = (credential: Credential) => {
private o365Auth = (credential: CredentialPayload) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
@ -192,7 +192,7 @@ export default class Office365CalendarService implements Calendar {
client_secret,
}),
});
const responseBody = await handleErrorsJson(response);
const responseBody = await handleErrorsJson<{ access_token: string; expires_in: number }>(response);
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
await prisma.credential.update({
@ -283,7 +283,7 @@ export default class Office365CalendarService implements Calendar {
}
const newResponse = await this.apiGraphBatchCall(newLinkRequest);
let newResponseBody = await handleErrorsJson(newResponse);
let newResponseBody = await handleErrorsJson<IBatchResponse | string>(newResponse);
if (typeof newResponseBody === "string") {
newResponseBody = this.handleTextJsonResponseWithHtmlInBody(newResponseBody);
@ -324,7 +324,7 @@ export default class Office365CalendarService implements Calendar {
await new Promise((r) => setTimeout(r, retryAfterTimeout));
const newResponses = await this.apiGraphBatchCall(failedRequest);
let newResponseBody = await handleErrorsJson(newResponses);
let newResponseBody = await handleErrorsJson<IBatchResponse | string>(newResponses);
if (typeof newResponseBody === "string") {
newResponseBody = this.handleTextJsonResponseWithHtmlInBody(newResponseBody);
}
@ -393,4 +393,21 @@ export default class Office365CalendarService implements Calendar {
[]
);
};
private handleErrorJsonOffice365Calendar = <Type>(response: Response): Promise<Type | string> => {
if (response.headers.get("content-encoding") === "gzip") {
return response.text();
}
if (response.status === 204) {
return new Promise((resolve) => resolve({} as Type));
}
if (!response.ok && response.status < 200 && response.status >= 300) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
};
}

View File

@ -1,9 +1,10 @@
import { Credential } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -32,8 +33,19 @@ interface O365AuthCredentials {
ext_expires_in: number;
}
interface ITokenResponse {
expiry_date: number;
expires_in?: number;
token_type: string;
scope: string;
access_token: string;
refresh_token: string;
error?: string;
error_description?: string;
}
// Checks to see if our O365 user token is valid or if we need to refresh
const o365Auth = async (credential: Credential) => {
const o365Auth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("msteams");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
@ -55,13 +67,14 @@ const o365Auth = async (credential: Credential) => {
client_secret,
}),
});
const responseBody = await handleErrorsJson(response);
if (responseBody.error) {
const responseBody = await handleErrorsJson<ITokenResponse>(response);
if (responseBody?.error) {
console.error(responseBody);
throw new HttpError({ statusCode: 500, message: "Error contacting MS Teams" });
}
// set expiry date as offset from current time.
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
responseBody.expiry_date = Math.round(Date.now() + (responseBody?.expires_in || 0) * 1000);
delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
@ -69,7 +82,8 @@ const o365Auth = async (credential: Credential) => {
id: credential.id,
},
data: {
key: responseBody,
// @NOTE: prisma doesn't know key its a JSON so do as responseBody
key: responseBody as unknown as Prisma.InputJsonValue,
},
});
o365AuthCredentials.expiry_date = responseBody.expiry_date;
@ -85,7 +99,7 @@ const o365Auth = async (credential: Credential) => {
};
};
const TeamsVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {

View File

@ -1,9 +1,10 @@
import { Credential } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -17,11 +18,25 @@ interface TandemToken {
access_token: string;
}
interface ITandemRefreshToken {
expires_in?: number;
expiry_date?: number;
access_token: string;
refresh_token: string;
}
interface ITandemCreateMeetingResponse {
data: {
id: string;
event_link: string;
};
}
let client_id = "";
let client_secret = "";
let base_url = "";
const tandemAuth = async (credential: Credential) => {
const tandemAuth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("tandem");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
@ -33,34 +48,33 @@ const tandemAuth = async (credential: Credential) => {
const credentialKey = credential.key as unknown as TandemToken;
const isTokenValid = (token: TandemToken) => token && token.access_token && token.expiry_date < Date.now();
const refreshAccessToken = (refreshToken: string) => {
fetch(`${base_url}/api/v1/oauth/v2/token`, {
const refreshAccessToken = async (refreshToken: string) => {
const result = await fetch(`${base_url}/api/v1/oauth/v2/token`, {
method: "POST",
body: new URLSearchParams({
client_id,
client_secret,
code: refreshToken,
}),
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// set expiry date as offset from current time.
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: responseBody,
},
});
credentialKey.expiry_date = responseBody.expiry_date;
credentialKey.access_token = responseBody.access_token;
credentialKey.refresh_token = responseBody.refresh_token;
return credentialKey.access_token;
});
});
const responseBody = await handleErrorsJson<ITandemRefreshToken>(result);
// set expiry date as offset from current time.
responseBody.expiry_date = Math.round(Date.now() + (responseBody.expires_in || 0) * 1000);
delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: responseBody as unknown as Prisma.InputJsonValue,
},
});
credentialKey.expiry_date = responseBody.expiry_date;
credentialKey.access_token = responseBody.access_token;
credentialKey.refresh_token = responseBody.refresh_token;
return credentialKey.access_token;
};
return {
@ -71,7 +85,7 @@ const tandemAuth = async (credential: Credential) => {
};
};
const TandemVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
const TandemVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const auth = tandemAuth(credential);
const _parseDate = (date: string) => {
@ -91,7 +105,7 @@ const TandemVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
});
};
const _translateResult = (result: { data: { id: string; event_link: string } }) => {
const _translateResult = (result: ITandemCreateMeetingResponse) => {
return {
type: "tandem_video",
id: result.data.id as string,
@ -115,9 +129,10 @@ const TandemVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
"Content-Type": "application/json",
},
body: _translateEvent(event, "meeting"),
}).then(handleErrorsJson);
});
const responseBody = await handleErrorsJson<ITandemCreateMeetingResponse>(result);
return Promise.resolve(_translateResult(result));
return Promise.resolve(_translateResult(responseBody));
},
deleteMeeting: async (uid: string): Promise<void> => {
@ -143,9 +158,10 @@ const TandemVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
"Content-Type": "application/json",
},
body: _translateEvent(event, "updates"),
}).then(handleErrorsJson);
});
const responseBody = await handleErrorsJson<ITandemCreateMeetingResponse>(result);
return Promise.resolve(_translateResult(result));
return Promise.resolve(_translateResult(responseBody));
},
};
};

View File

@ -1,11 +1,11 @@
import { Credential } from "@prisma/client";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { handleErrorsJson } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { Credential } from "@calcom/prisma/client";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -69,7 +69,7 @@ const zoomRefreshedTokenSchema = z.object({
scope: z.string(),
});
const zoomAuth = (credential: Credential) => {
const zoomAuth = (credential: CredentialPayload) => {
const refreshAccessToken = async (refreshToken: string) => {
const { client_id, client_secret } = await getZoomAppKeys();
const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64");
@ -86,8 +86,13 @@ const zoomAuth = (credential: Credential) => {
}),
});
const responseBody = await handleZoomResponse(response);
const responseBody = await handleZoomResponse(response, credential.id);
if (responseBody.error) {
if (responseBody.error === "invalid_grant") {
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
}
}
// We check the if the new credentials matches the expected response structure
const parsedToken = zoomRefreshedTokenSchema.safeParse(responseBody);
@ -138,7 +143,7 @@ type ZoomRecurrence = {
monthly_day?: number; // 1-31
};
const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const translateEvent = (event: CalendarEvent) => {
const getRecurrence = ({
recurringEvent,
@ -232,7 +237,7 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
...options?.headers,
},
});
const responseBody = await handleErrorsJson(response);
const responseBody = await handleZoomResponse(response, credential.id);
return responseBody;
};
@ -241,6 +246,7 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
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,
@ -254,13 +260,19 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
try {
const response: ZoomEventResult = await fetchZoomApi("users/me/meetings", {
const response = await fetchZoomApi("users/me/meetings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
});
if (response.error) {
if (response.error === "invalid_grant") {
await invalidateCredential(credential.id);
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
}
}
const result = zoomEventResultSchema.parse(response);
@ -280,42 +292,73 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
}
},
deleteMeeting: async (uid: string): Promise<void> => {
await fetchZoomApi(`meetings/${uid}`, {
method: "DELETE",
});
return Promise.resolve();
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> => {
await fetchZoomApi(`meetings/${bookingRef.uid}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
});
try {
await fetchZoomApi(`meetings/${bookingRef.uid}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
});
return Promise.resolve({
type: "zoom_video",
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
});
return Promise.resolve({
type: "zoom_video",
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
});
} catch (err) {
return Promise.reject(new Error("Failed to update meeting"));
}
},
};
};
const handleZoomResponse = async (response: Response) => {
if (response.headers.get("content-encoding") === "gzip") {
const handleZoomResponse = async (response: Response, credentialId: Credential["id"]) => {
let _response = response.clone();
if (_response.headers.get("content-encoding") === "gzip") {
const responseString = await response.text();
return JSON.parse(responseString);
_response = JSON.parse(responseString);
}
if (!response.ok && response.status < 200 && response.status >= 300) {
response.json().then(console.log);
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await _response.json();
if ((response && response.status === 124) || responseBody.error === "invalid_grant") {
await invalidateCredential(credentialId);
}
throw Error(response.statusText);
}
return response.json();
};
const invalidateCredential = async (credentialId: Credential["id"]) => {
const credential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (credential) {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
invalid: true,
},
});
}
};
export default ZoomVideoApiAdapter;

View File

@ -1,21 +1,20 @@
import { Credential, SelectedCalendar } from "@prisma/client";
import { SelectedCalendar } from "@prisma/client";
import { createHash } from "crypto";
import _ from "lodash";
import cache from "memory-cache";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import getApps from "@calcom/app-store/utils";
import type { ExtendedCredential } from "@calcom/core/EventManager";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import { performance } from "@calcom/lib/server/perfObserver";
import { App } from "@calcom/types/App";
import type { CalendarEvent, EventBusyDate, NewCalendarEventType } from "@calcom/types/Calendar";
import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
export const getCalendarCredentials = (credentials: Array<Credential>) => {
export const getCalendarCredentials = (credentials: Array<CredentialPayload>) => {
const calendarCredentials = getApps(credentials)
.filter((app) => app.type.endsWith("_calendar"))
.flatMap((app) => {
@ -105,8 +104,8 @@ export const getConnectedCalendars = async (
*/
const cleanIntegrationKeys = (
appIntegration: ReturnType<typeof getCalendarCredentials>[number]["integration"] & {
credentials?: Array<Credential>;
credential: Credential;
credentials?: Array<CredentialPayload>;
credential: CredentialPayload;
}
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -117,7 +116,7 @@ const cleanIntegrationKeys = (
const CACHING_TIME = 30_000; // 30 seconds
const getCachedResults = async (
withCredentials: Credential[],
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
@ -170,7 +169,7 @@ const getCachedResults = async (
};
export const getBusyCalendarTimes = async (
withCredentials: Credential[],
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
@ -185,7 +184,7 @@ export const getBusyCalendarTimes = async (
};
export const createEvent = async (
credential: ExtendedCredential,
credential: CredentialWithAppName,
calEvent: CalendarEvent
): Promise<EventResult<NewCalendarEventType>> => {
const uid: string = getUid(calEvent);
@ -228,7 +227,7 @@ export const createEvent = async (
};
export const updateEvent = async (
credential: ExtendedCredential,
credential: CredentialWithAppName,
calEvent: CalendarEvent,
bookingRefUid: string | null,
externalCalendarId: string | null
@ -266,7 +265,11 @@ export const updateEvent = async (
};
};
export const deleteEvent = (credential: Credential, uid: string, event: CalendarEvent): Promise<unknown> => {
export const deleteEvent = (
credential: CredentialPayload,
uid: string,
event: CalendarEvent
): Promise<unknown> => {
const calendar = getCalendar(credential);
if (calendar) {
return calendar.deleteEvent(uid, event);

View File

@ -1,4 +1,4 @@
import { Credential, DestinationCalendar } from "@prisma/client";
import { DestinationCalendar } from "@prisma/client";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
@ -9,6 +9,7 @@ import getApps from "@calcom/app-store/utils";
import prisma from "@calcom/prisma";
import { createdEventSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar";
import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential";
import type { Event } from "@calcom/types/Event";
import type {
CreateUpdateResult,
@ -58,17 +59,15 @@ export const processLocation = (event: CalendarEvent): CalendarEvent => {
};
type EventManagerUser = {
credentials: Credential[];
credentials: CredentialPayload[];
destinationCalendar: DestinationCalendar | null;
};
type createdEventSchema = z.infer<typeof createdEventSchema>;
export type ExtendedCredential = Credential & { appName: string };
export default class EventManager {
calendarCredentials: ExtendedCredential[];
videoCredentials: ExtendedCredential[];
calendarCredentials: CredentialWithAppName[];
videoCredentials: CredentialWithAppName[];
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
@ -359,7 +358,7 @@ export default class EventManager {
* @private
*/
private getVideoCredential(event: CalendarEvent): ExtendedCredential | undefined {
private getVideoCredential(event: CalendarEvent): CredentialWithAppName | undefined {
if (!event.location) {
return undefined;
}
@ -373,7 +372,7 @@ export default class EventManager {
.sort((a, b) => {
return b.id - a.id;
})
.find((credential: Credential) => credential.type.includes(integrationName));
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
/**
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.

View File

@ -1,14 +1,13 @@
import { Credential } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import appStore from "@calcom/app-store";
import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys";
import type { ExtendedCredential } from "@calcom/core/EventManager";
import { sendBrokenIntegrationEmail } from "@calcom/emails";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoApiAdapterFactory, VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -17,7 +16,7 @@ const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
// factory
const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
const getVideoAdapters = (withCredentials: CredentialPayload[]): VideoApiAdapter[] =>
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const app = appStore[appName as keyof typeof appStore];
@ -30,12 +29,12 @@ const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
return acc;
}, []);
const getBusyVideoTimes = (withCredentials: Credential[]) =>
const getBusyVideoTimes = (withCredentials: CredentialPayload[]) =>
Promise.all(getVideoAdapters(withCredentials).map((c) => c?.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [] as (EventBusyDate | undefined)[])
);
const createMeeting = async (credential: ExtendedCredential, calEvent: CalendarEvent) => {
const createMeeting = async (credential: CredentialWithAppName, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent);
if (!credential) {
@ -82,7 +81,7 @@ const createMeeting = async (credential: ExtendedCredential, calEvent: CalendarE
};
const updateMeeting = async (
credential: ExtendedCredential,
credential: CredentialWithAppName,
calEvent: CalendarEvent,
bookingRef: PartialReference | null
): Promise<EventResult<VideoCallData>> => {
@ -121,7 +120,7 @@ const updateMeeting = async (
};
};
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
const deleteMeeting = (credential: CredentialPayload, uid: string): Promise<unknown> => {
if (credential) {
const videoAdapter = getVideoAdapters([credential])[0];
// There are certain video apps with no video adapter defined. e.g. riverby,whereby

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../types/ical.d.ts"/>
import { Credential, Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import ICAL from "ical.js";
import type { Attendee, DateArray, DurationObject, Person } from "ics";
import { createEvent } from "ics";
@ -26,6 +26,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import { getLocation, getRichDescription } from "./CalEventParser";
import { symmetricDecrypt } from "./crypto";
@ -68,7 +69,7 @@ export default abstract class BaseCalendarService implements Calendar {
protected integrationName = "";
private log: typeof logger;
constructor(credential: Credential, integrationName: string, url?: string) {
constructor(credential: CredentialPayload, integrationName: string, url?: string) {
this.integrationName = integrationName;
const {

View File

@ -10,13 +10,16 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb
return new Error(`Unhandled error of type '${typeof cause}''`);
}
export function handleErrorsJson(response: Response) {
export async function handleErrorsJson<Type>(response: Response): Promise<Type> {
if (response.headers.get("content-encoding") === "gzip") {
return response.text();
const responseText = await response.text();
return new Promise((resolve) => resolve(JSON.parse(responseText)));
}
if (response.status === 204) {
return new Promise((resolve) => resolve({}));
return new Promise((resolve) => resolve({} as Type));
}
if (!response.ok && response.status < 200 && response.status >= 300) {
response.json().then(console.log);
throw Error(response.statusText);

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Credential" ADD COLUMN "invalid" BOOLEAN;

View File

@ -100,6 +100,7 @@ model Credential {
// How to make it a required column?
appId String?
destinationCalendars DestinationCalendar[]
invalid Boolean?
}
enum UserPlan {

View File

@ -58,6 +58,7 @@ async function getUserFromSession({
key: true,
userId: true,
appId: true,
invalid: true,
},
orderBy: {
id: "asc",

View File

@ -772,11 +772,19 @@ const loggedInViewerRouter = createProtectedRouter()
const { variant, exclude, onlyInstalled } = input;
const { credentials } = user;
let apps = getApps(credentials).map(
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => ({
...app,
credentialIds: credentials.filter((c) => c.type === app.type).map((c) => c.id),
})
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => {
const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id);
const invalidCredentialIds = credentials
.filter((c) => c.type === app.type && c.invalid)
.map((c) => c.id);
return {
...app,
credentialIds,
invalidCredentialIds,
};
}
);
if (exclude) {
// exclusion filter
apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true));

18
packages/types/Credential.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
import { Prisma } from ".prisma/client";
/*
* The logic on this it's just using Credential Type doesn't reflect that some fields can be
* null sometimes, so with this we should get correct type.
* Also there may be a better place to save this.
*/
export type CredentialPayload = Prisma.CredentialGetPayload<{
select: {
id: true;
appId: true;
type: true;
userId: true;
key: true;
};
}>;
export type CredentialWithAppName = CredentialPayload & { appName: string };

View File

@ -1,6 +1,5 @@
import type { Credential } from "@prisma/client";
import type { EventBusyDate } from "./Calendar";
import { CredentialPayload } from "./Credential";
export interface VideoCallData {
type: string;
@ -22,4 +21,4 @@ export type VideoApiAdapter =
}
| undefined;
export type VideoApiAdapterFactory = (credential: Credential) => VideoApiAdapter;
export type VideoApiAdapterFactory = (credential: CredentialPayload) => VideoApiAdapter;

View File

@ -30,8 +30,8 @@ import Dropdown, {
import { Icon } from "@calcom/ui/Icon";
import TimezoneChangeDialog from "@calcom/ui/TimezoneChangeDialog";
import Button from "@calcom/ui/v2/core/Button";
import Tips from "@calcom/ui/v2/modules/tips/Tips";
import showToast from "@calcom/ui/v2/core/notifications";
import Tips from "@calcom/ui/v2/modules/tips/Tips";
/* TODO: Get this from endpoint */
import pkg from "../../../../apps/web/package.json";