cal.pub0.org/packages/app-store/basecamp3/lib/CalendarService.ts

282 lines
9.4 KiB
TypeScript

import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type {
Calendar,
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { refreshAccessToken as getNewTokens } from "./helpers";
function hasFileExtension(url: string): boolean {
// Get the last portion of the URL (after the last '/')
const fileName = url.substring(url.lastIndexOf("/") + 1);
// Check if the file name has a '.' in it and no '/' after the '.'
return fileName.includes(".") && !fileName.substring(fileName.lastIndexOf(".")).includes("/");
}
function getFileExtension(url: string): string {
// Return null if the URL does not have a file extension
if (!hasFileExtension(url)) return "ics";
// Get the last portion of the URL (after the last '/')
const fileName = url.substring(url.lastIndexOf("/") + 1);
// Extract the file extension
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
export type BasecampToken = {
projectId: number;
expires_at: number;
expires_in: number;
scheduleId: number;
access_token: string;
refresh_token: string;
account: {
id: number;
href: string;
name: string;
hidden: boolean;
product: string;
app_href: string;
};
};
export default class BasecampCalendarService implements Calendar {
private credentials: Record<string, string> = {};
private auth: Promise<{ configureToken: () => Promise<void> }>;
private headers: Record<string, string> = {};
private userAgent = "";
protected integrationName = "";
private accessToken = "";
private scheduleId = 0;
private userId = 0;
private projectId = 0;
private log: typeof logger;
constructor(credential: CredentialPayload) {
this.integrationName = "basecamp3";
getAppKeysFromSlug("basecamp3").then(({ user_agent }: any) => {
this.userAgent = user_agent as string;
});
this.auth = this.basecampAuth(credential).then((c) => c);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private basecampAuth = async (credential: CredentialPayload) => {
const credentialKey = credential.key as BasecampToken;
this.scheduleId = credentialKey.scheduleId;
this.userId = credentialKey.account.id;
this.projectId = credentialKey.projectId;
const isTokenValid = (credentialToken: BasecampToken) => {
const isValid = credentialToken.access_token && credentialToken.expires_at > Date.now();
if (isValid) this.accessToken = credentialToken.access_token;
return isValid;
};
const refreshAccessToken = async (credentialToken: CredentialPayload) => {
try {
const newCredentialKey = (await getNewTokens(credentialToken)) as BasecampToken;
this.accessToken = newCredentialKey.access_token;
} catch (err) {
this.log.error(err);
}
};
return {
configureToken: () =>
isTokenValid(credentialKey) ? Promise.resolve() : refreshAccessToken(credential),
};
};
private async getBasecampDescription(event: CalendarEvent): Promise<string> {
const timeZone = await this.getUserTimezoneFromDB(event.organizer?.id as number);
const date = new Date(event.startTime).toDateString();
const startTime = new Date(event.startTime).toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
minute: "numeric",
});
const endTime = new Date(event.endTime).toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
minute: "numeric",
});
const baseString = `<div>Event title: ${event.title}<br/>Date and time: ${date}, ${startTime} - ${endTime} ${timeZone}<br/>View on Cal.com: <a target="_blank" rel="noreferrer" class="autolinked" data-behavior="truncate" href="https://app.cal.com/booking/${event.uid}">https://app.cal.com/booking/${event.uid}</a> `;
const guestString =
"<br/>Guests: " +
event.attendees.reduce((acc, attendee) => {
return (
acc +
`<br/><a target=\"_blank\" rel=\"noreferrer\" class=\"autolinked\" data-behavior=\"truncate\" href=\"mailto:${attendee.email}\">${attendee.email}</a>`
);
}, "");
const videoString = event.videoCallData
? `<br/>Join on video: ${event.videoCallData.url}</div>`
: "</div>";
return baseString + guestString + videoString;
}
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
try {
const auth = await this.auth;
await auth.configureToken();
const description = await this.getBasecampDescription(event);
const basecampEvent = await fetch(
`https://3.basecampapi.com/${this.userId}/buckets/${this.projectId}/schedules/${this.scheduleId}/entries.json`,
{
method: "POST",
headers: {
"User-Agent": this.userAgent,
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
description,
summary: `Cal.com: ${event.title}`,
starts_at: new Date(event.startTime).toISOString(),
ends_at: new Date(event.endTime).toISOString(),
}),
}
);
const meetingJson = await basecampEvent.json();
const id = meetingJson.id;
this.log.debug("event:creation:ok", { json: meetingJson });
return Promise.resolve({
id,
uid: id,
type: this.integrationName,
password: "",
url: "",
additionalInfo: { meetingJson },
});
} catch (err) {
this.log.debug("event:creation:notOk", err);
return Promise.reject({ error: "Unable to book basecamp meeting" });
}
}
async updateEvent(
uid: string,
event: CalendarEvent
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
try {
const auth = await this.auth;
await auth.configureToken();
const description = await this.getBasecampDescription(event);
const basecampEvent = await fetch(
`https://3.basecampapi.com/${this.userId}/buckets/${this.projectId}/schedule_entries/${uid}.json`,
{
method: "PUT",
headers: {
"User-Agent": this.userAgent,
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
description,
summary: `Cal.com: ${event.title}`,
starts_at: new Date(event.startTime).toISOString(),
ends_at: new Date(event.endTime).toISOString(),
}),
}
);
const meetingJson = await basecampEvent.json();
const id = meetingJson.id;
return {
uid: id,
type: event.type,
id,
password: "",
url: "",
additionalInfo: { meetingJson },
};
} catch (reason) {
this.log.error(reason);
throw reason;
}
}
async deleteEvent(uid: string): Promise<void> {
try {
const auth = await this.auth;
await auth.configureToken();
const deletedEventResponse = await fetch(
`https://3.basecampapi.com/${this.userId}/buckets/${this.projectId}/recordings/${uid}/status/trashed.json`,
{
method: "PUT",
headers: {
"User-Agent": this.userAgent,
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
}
);
if (deletedEventResponse.ok) {
Promise.resolve("Deleted basecamp meeting");
} else Promise.reject("Error cancelling basecamp event");
} catch (reason) {
this.log.error(reason);
throw reason;
}
}
/**
* getUserTimezoneFromDB() retrieves the timezone of a user from the database.
*
* @param {number} id - The user's unique identifier.
* @returns {Promise<string | undefined>} - A Promise that resolves to the user's timezone or "Europe/London" as a default value if the timezone is not found.
*/
getUserTimezoneFromDB = async (id: number): Promise<string | undefined> => {
const user = await prisma.user.findUnique({
where: {
id,
},
select: {
timeZone: true,
},
});
return user?.timeZone;
};
/**
* getUserId() extracts the user ID from the first calendar in an array of IntegrationCalendars.
*
* @param {IntegrationCalendar[]} selectedCalendars - An array of IntegrationCalendars.
* @returns {number | null} - The user ID associated with the first calendar in the array, or null if the array is empty or the user ID is not found.
*/
getUserId = (selectedCalendars: IntegrationCalendar[]): number | null => {
if (selectedCalendars.length === 0) {
return null;
}
return selectedCalendars[0].userId || null;
};
isValidFormat = (url: string): boolean => {
const allowedExtensions = ["eml", "ics"];
const urlExtension = getFileExtension(url);
if (!allowedExtensions.includes(urlExtension)) {
console.error(`Unsupported calendar object format: ${urlExtension}`);
return false;
}
return true;
};
async getAvailability(
_dateFrom: string,
_dateTo: string,
_selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return Promise.resolve([]);
}
async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}
}