feat: add basecamp integration to cal.com (#9195)
* feat: installing the app works * Update yarn.lock * feat: /api/callback now gets user auth info from basecamp * feat: updated basecamp logo * feat: added project dropdown on event apps page * feat: basecamp event creation and deletion working * feat: basecamp event rescheduling now works * refactor(CalendarService): basecamp CaldendarService code clean up * refactor: code cleanup for basecamp app API * feat: updated event summary text sent to basecamp * chore: updated basecamp images and contact info * fix: fixed typescript errors and added logic to refresh tokens on event settings * refactor(CaldendarService): used refreshAccessToken from helpers.ts instead * chore: updated basecamp description * fix: fixed incorrect import * fix: accidentally deleted props to toggle app for event * chore: updated .env.appStore.example and added README for app * Update .env.appStore.example Co-authored-by: Leo Giovanetti <hello@leog.me> * feat: added basecamp userAgent in env instead of hardcoded value * feat: updated README to include how to set basecamp user agent env * fix: removed unused import * feat: used URLSearchParams to construct url params * fix: fixed typescript errors * chore: updated README to include an example on how to set basecamp user-agent * feat: using TRPC instead of REST * chore: removed old projects REST code --------- Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
parent
3f273235a6
commit
f80e9b2558
|
@ -1,6 +1,7 @@
|
|||
# ********** INDEX **********
|
||||
#
|
||||
# - APP STORE
|
||||
# - BASECAMP
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
|
@ -20,6 +21,14 @@
|
|||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
|
||||
# - BASECAMP
|
||||
# Used to enable Basecamp integration with Cal.com
|
||||
# @see https://github.com/calcom/cal.com#obtaining-basecamp-client-id-and-secret
|
||||
BASECAMP3_CLIENT_ID=
|
||||
BASECAMP3_CLIENT_SECRET=
|
||||
BASECAMP3_USER_AGENT=
|
||||
|
||||
# - DAILY.CO VIDEO
|
||||
# Enables Cal Video. to get your key
|
||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
|
|
12
README.md
12
README.md
|
@ -461,6 +461,18 @@ following
|
|||
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
||||
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining Basecamp Client ID and Secret
|
||||
|
||||
1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in.
|
||||
2. Register a new application by clicking the Register one now link.
|
||||
3. Fill in your company details.
|
||||
4. Select Basecamp 4 as the product to integrate with.
|
||||
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
||||
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||
For example, `Cal.com (support@cal.com)`.
|
||||
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
import appBasecamp3 from "@calcom/app-store/basecamp3/trpc-router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(appBasecamp3);
|
|
@ -20,6 +20,7 @@ export const AppSettingsComponentsMap = {
|
|||
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
|
||||
};
|
||||
export const EventTypeAddonMap = {
|
||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
import { appKeysSchema as ga4_zod_ts } from "./ga4/zod";
|
||||
|
@ -32,6 +33,7 @@ import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appKeysSchemas = {
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
ga4: ga4_zod_ts,
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import amie_config_json from "./amie/config.json";
|
||||
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
|
||||
import around_config_json from "./around/config.json";
|
||||
import basecamp3_config_json from "./basecamp3/config.json";
|
||||
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import closecom_config_json from "./closecom/config.json";
|
||||
|
@ -73,6 +74,7 @@ export const appStoreMetadata = {
|
|||
amie: amie_config_json,
|
||||
applecalendar: applecalendar__metadata_ts,
|
||||
around: around_config_json,
|
||||
basecamp3: basecamp3_config_json,
|
||||
caldavcalendar: caldavcalendar__metadata_ts,
|
||||
campfire: campfire_config_json,
|
||||
closecom: closecom_config_json,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
import { appDataSchema as ga4_zod_ts } from "./ga4/zod";
|
||||
|
@ -32,6 +33,7 @@ import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appDataSchemas = {
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
ga4: ga4_zod_ts,
|
||||
|
|
|
@ -6,6 +6,7 @@ export const apiHandlers = {
|
|||
amie: import("./amie/api"),
|
||||
applecalendar: import("./applecalendar/api"),
|
||||
around: import("./around/api"),
|
||||
basecamp3: import("./basecamp3/api"),
|
||||
caldavcalendar: import("./caldavcalendar/api"),
|
||||
campfire: import("./campfire/api"),
|
||||
closecom: import("./closecom/api"),
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 3.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
|
@ -0,0 +1,35 @@
|
|||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getBasecampKeys } from "../lib/getBasecampKeys";
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { client_id } = await getBasecampKeys();
|
||||
|
||||
const params = {
|
||||
type: "web_server",
|
||||
client_id,
|
||||
};
|
||||
const query = stringify(params);
|
||||
const url = `https://launchpad.37signals.com/authorization/new?${query}&redirect_uri=${
|
||||
WEBAPP_URL + "/api/integrations/basecamp3/callback"
|
||||
}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
|
@ -0,0 +1,90 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import appConfig from "../config.json";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret, user_agent } = await getAppKeysFromSlug("basecamp3");
|
||||
|
||||
const redirectUri = WEBAPP_URL + "/api/integrations/basecamp3/callback";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
type: "web_server",
|
||||
client_id: client_id as string,
|
||||
client_secret: client_secret as string,
|
||||
redirect_uri: redirectUri,
|
||||
code: code as string,
|
||||
});
|
||||
// gets access token
|
||||
const accessTokenResponse = await fetch(
|
||||
`https://launchpad.37signals.com/authorization/token?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (accessTokenResponse.status !== 200) {
|
||||
let errorMessage = "Error with Basecamp 3 API";
|
||||
try {
|
||||
const responseBody = await accessTokenResponse.json();
|
||||
errorMessage = responseBody.error;
|
||||
} catch (e) {}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenResponseBody = await accessTokenResponse.json();
|
||||
|
||||
if (tokenResponseBody.error) {
|
||||
res.status(400).json({ message: tokenResponseBody.error });
|
||||
return;
|
||||
}
|
||||
// expiry date of 2 weeks
|
||||
tokenResponseBody["expires_at"] = Date.now() + 1000 * 3600 * 24 * 14;
|
||||
// get user details such as projects and account info
|
||||
const userAuthResponse = await fetch("https://launchpad.37signals.com/authorization.json", {
|
||||
headers: {
|
||||
"User-Agent": user_agent as string,
|
||||
Authorization: `Bearer ${tokenResponseBody.access_token}`,
|
||||
},
|
||||
});
|
||||
if (userAuthResponse.status !== 200) {
|
||||
let errorMessage = "Error with Basecamp 3 API";
|
||||
try {
|
||||
const body = await userAuthResponse.json();
|
||||
errorMessage = body.error;
|
||||
} catch (e) {}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const authResponseBody = await userAuthResponse.json();
|
||||
const userId = req.session?.user.id;
|
||||
if (!userId) {
|
||||
return res.status(404).json({ message: "No user found" });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: req.session?.user.id,
|
||||
},
|
||||
data: {
|
||||
credentials: {
|
||||
create: {
|
||||
type: appConfig.type,
|
||||
key: { ...tokenResponseBody, account: authResponseBody.accounts[0] },
|
||||
appId: appConfig.slug,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }));
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
|
@ -0,0 +1,73 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||
const [projects, setProjects] = useState();
|
||||
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
||||
const { data } = trpc.viewer.appBasecamp3.projects.useQuery();
|
||||
const setProject = trpc.viewer.appBasecamp3.projectMutation.useMutation();
|
||||
useEffect(() => {
|
||||
setSelectedProject({
|
||||
value: data?.projects.currentProject,
|
||||
label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name,
|
||||
});
|
||||
setProjects(
|
||||
data?.projects?.map((project: any) => {
|
||||
return {
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchOnClick={(e) => {
|
||||
if (!e) {
|
||||
setEnabled(false);
|
||||
} else {
|
||||
setEnabled(true);
|
||||
}
|
||||
}}
|
||||
switchChecked={enabled}>
|
||||
<div className="mt-2 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<div className="items-center">
|
||||
<p className="py-2">Link a Basecamp project to this event:</p>
|
||||
</div>
|
||||
<Select
|
||||
placeholder="Select project"
|
||||
options={projects}
|
||||
isLoading={!projects}
|
||||
className="md:min-w-[120px]"
|
||||
onChange={(project) => {
|
||||
if (project) {
|
||||
setProject.mutate({ projectId: project?.value.toString() });
|
||||
setSelectedProject(project);
|
||||
}
|
||||
}}
|
||||
value={selectedProject}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
Please note that as of now you can only link <span className="italic">one</span> of your projects to
|
||||
cal.com
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "Basecamp3",
|
||||
"slug": "basecamp3",
|
||||
"type": "basecamp3_other_calendar",
|
||||
"logo": "logo.svg",
|
||||
"url": "https://basecamp.com/",
|
||||
"variant": "other",
|
||||
"categories": ["other"],
|
||||
"publisher": "Jonathan D'mello",
|
||||
"email": "support@cal.com",
|
||||
"description": "Basecamp puts everything you need to get work done in one place. It’s the calm, organized way to manage projects, work with clients, and communicate company-wide.",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "basecamp3"
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
|
@ -0,0 +1,281 @@
|
|||
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([]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getBasecampKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("basecamp3");
|
||||
return appKeysSchema.parse(appKeys);
|
||||
};
|
||||
|
||||
const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
user_agent: z.string().min(1),
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import type { BasecampToken } from "./CalendarService";
|
||||
import { getBasecampKeys } from "./getBasecampKeys";
|
||||
|
||||
export const refreshAccessToken = async (credential: CredentialPayload) => {
|
||||
const { client_id: clientId, client_secret: clientSecret, user_agent: userAgent } = await getBasecampKeys();
|
||||
const credentialKey = credential.key as BasecampToken;
|
||||
const tokenInfo = await fetch(
|
||||
`https://launchpad.37signals.com/authorization/token?type=refresh&refresh_token=${credentialKey.refresh_token}&client_id=${clientId}&redirect_uri=${WEBAPP_URL}&client_secret=${clientSecret}`,
|
||||
{ method: "POST", headers: { "User-Agent": userAgent } }
|
||||
);
|
||||
const tokenInfoJson = await tokenInfo.json();
|
||||
tokenInfoJson["expires_at"] = Date.now() + 1000 * 3600 * 24 * 14;
|
||||
const newCredential = await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: {
|
||||
key: { ...credentialKey, ...tokenInfoJson },
|
||||
},
|
||||
});
|
||||
return newCredential.key;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { default as CalendarService } from "./CalendarService";
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/basecamp3",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Basecamp puts everything you need to get work done in one place. It's the calm, organized way to manage projects, work with clients, and communicate company-wide."
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 476 KiB |
Binary file not shown.
After Width: | Height: | Size: 605 KiB |
Binary file not shown.
After Width: | Height: | Size: 144 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88.74 49.93"><defs><style>.cls-1{fill:#1d2d35;}.cls-2{fill:none;stroke:#1d2d35;stroke-linecap:round;stroke-linejoin:round;stroke-width:3.41px;}</style></defs><title>basecamp-logo-stacked</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M1.44,33.19H6.71c2.93,0,4.45,1.2,4.45,3.48v.08A2.76,2.76,0,0,1,9,39.62a2.94,2.94,0,0,1,2.68,3.15v.07c0,2.46-1.61,3.75-4.7,3.75H1.44ZM6.23,38.7c1.46,0,2-.53,2-1.73V36.9c0-1.13-.64-1.63-2.06-1.63H4.41V38.7Zm.26,5.79c1.5,0,2.17-.67,2.17-1.89v-.07c0-1.24-.67-1.88-2.32-1.88H4.41v3.84Z" transform="translate(-1.44)"/><path class="cls-1" d="M12.45,43.88c0-2.29,2.1-3.16,5.1-3.16h1.11v-.39c0-1.16-.36-1.8-1.6-1.8a1.48,1.48,0,0,0-1.67,1.39H12.82c.17-2.33,2-3.36,4.41-3.36s4.11,1,4.11,3.64v6.39H18.69V45.41a3.32,3.32,0,0,1-3,1.39C14,46.8,12.45,45.94,12.45,43.88Zm6.21-.72v-.82H17.61c-1.58,0-2.5.34-2.5,1.39,0,.71.43,1.18,1.43,1.18C17.74,44.91,18.66,44.25,18.66,43.16Z" transform="translate(-1.44)"/><path class="cls-1" d="M22.48,43.52H25c.11.88.55,1.39,1.73,1.39s1.53-.4,1.53-1.07-.58-1-2-1.16c-2.61-.4-3.62-1.15-3.62-3.06s1.87-3.06,3.88-3.06c2.18,0,3.81.79,4.05,3H28.11c-.15-.81-.6-1.18-1.54-1.18s-1.39.41-1.39,1,.47.86,1.89,1.07c2.46.36,3.81,1,3.81,3.07s-1.54,3.23-4.12,3.23S22.57,45.64,22.48,43.52Z" transform="translate(-1.44)"/><path class="cls-1" d="M31.57,41.81v-.15a4.88,4.88,0,0,1,5-5.1c2.53,0,4.78,1.48,4.78,5v.75H34.33c.08,1.63,1,2.57,2.44,2.57,1.25,0,1.87-.55,2-1.37h2.57c-.32,2.12-2,3.3-4.69,3.3A4.76,4.76,0,0,1,31.57,41.81Zm7.19-1.18c-.1-1.48-.85-2.19-2.14-2.19a2.24,2.24,0,0,0-2.25,2.19Z" transform="translate(-1.44)"/><path class="cls-1" d="M42.11,41.81v-.15a4.87,4.87,0,0,1,5.06-5.1c2.27,0,4.37,1,4.62,3.81H49.22a1.8,1.8,0,0,0-2-1.67c-1.41,0-2.34,1-2.34,2.92v.16c0,2,.88,3,2.4,3a2,2,0,0,0,2.1-1.9h2.45c-.15,2.33-1.83,3.94-4.68,3.94A4.71,4.71,0,0,1,42.11,41.81Z" transform="translate(-1.44)"/><path class="cls-1" d="M52.48,43.88c0-2.29,2.1-3.16,5.1-3.16h1.11v-.39c0-1.16-.36-1.8-1.6-1.8a1.48,1.48,0,0,0-1.67,1.39H52.85c.17-2.33,2-3.36,4.41-3.36s4.11,1,4.11,3.64v6.39H58.72V45.41a3.32,3.32,0,0,1-3,1.39C54,46.8,52.48,45.94,52.48,43.88Zm6.21-.72v-.82h-1c-1.58,0-2.5.34-2.5,1.39,0,.71.43,1.18,1.43,1.18C57.77,44.91,58.69,44.25,58.69,43.16Z" transform="translate(-1.44)"/><path class="cls-1" d="M63.22,36.79h2.72v1.5a3.34,3.34,0,0,1,3-1.73,2.71,2.71,0,0,1,2.74,1.71A3.9,3.9,0,0,1,75,36.56c1.82,0,3.23,1.15,3.23,3.75v6.28h-2.7v-6c0-1.25-.57-1.82-1.54-1.82a1.83,1.83,0,0,0-1.9,2v5.79h-2.7v-6c0-1.25-.58-1.82-1.53-1.82a1.83,1.83,0,0,0-1.9,2v5.79H63.22Z" transform="translate(-1.44)"/><path class="cls-1" d="M80.06,36.79h2.72v1.53a3.65,3.65,0,0,1,3.13-1.76c2.4,0,4.28,1.78,4.28,5v.15c0,3.24-1.84,5.06-4.28,5.06a3.42,3.42,0,0,1-3.13-1.71v4.84H80.06Zm7.37,5v-.15c0-2-1-3-2.34-3s-2.4,1-2.4,3v.15c0,2,.93,2.92,2.41,2.92S87.43,43.67,87.43,41.76Z" transform="translate(-1.44)"/><path class="cls-2" d="M26.2,17.73c2.61-7.4,7.62-16,17.7-16S59,15,59.61,24c-2.65,4.6-9,6.33-15.71,6.33A18.87,18.87,0,0,1,29.45,24s3.17-8.53,6.11-8.55c2.17,0,4,4,5.73,4s5.33-7.68,5.33-7.68" transform="translate(-1.44)"/></g></g></svg>
|
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1 @@
|
|||
export { default } from "./trpc/_router";
|
|
@ -0,0 +1,25 @@
|
|||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { ZProjectMutationInputSchema } from "./projectMutation.schema";
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: any = {};
|
||||
|
||||
const appBasecamp3 = router({
|
||||
projects: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.projects) {
|
||||
UNSTABLE_HANDLER_CACHE.projects = await import("./projects.handler").then((mod) => mod.projectHandler);
|
||||
}
|
||||
return UNSTABLE_HANDLER_CACHE.projects({ ctx });
|
||||
}),
|
||||
projectMutation: authedProcedure.input(ZProjectMutationInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.projectMutation) {
|
||||
UNSTABLE_HANDLER_CACHE.projectMutation = await import("./projectMutation.handler").then(
|
||||
(mod) => mod.projectMutationHandler
|
||||
);
|
||||
}
|
||||
return UNSTABLE_HANDLER_CACHE.projectMutation({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
export default appBasecamp3;
|
|
@ -0,0 +1,56 @@
|
|||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import type { BasecampToken } from "../lib/CalendarService";
|
||||
import { refreshAccessToken } from "../lib/helpers";
|
||||
import type { TProjectMutationInputSchema } from "./projectMutation.schema";
|
||||
|
||||
interface ProjectMutationHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TProjectMutationInputSchema;
|
||||
}
|
||||
|
||||
export const projectMutationHandler = async ({ ctx, input }: ProjectMutationHandlerOptions) => {
|
||||
const { user_agent } = await getAppKeysFromSlug("basecamp3");
|
||||
const { user, prisma } = ctx;
|
||||
|
||||
const { projectId } = input;
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "No credential found for user" });
|
||||
}
|
||||
let credentialKey = credential.key as BasecampToken;
|
||||
|
||||
if (credentialKey.expires_at < Date.now()) {
|
||||
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
|
||||
}
|
||||
// get schedule id
|
||||
const basecampUserId = credentialKey.account.id;
|
||||
const scheduleResponse = await fetch(
|
||||
`https://3.basecampapi.com/${basecampUserId}/projects/${projectId}.json`,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": user_agent as string,
|
||||
Authorization: `Bearer ${credentialKey.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const scheduleJson = await scheduleResponse.json();
|
||||
const scheduleId = scheduleJson.dock.find((dock: any) => dock.name === "schedule").id;
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { key: { ...credentialKey, projectId: Number(projectId), scheduleId } },
|
||||
});
|
||||
|
||||
return { messsage: "Updated project successfully" };
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZProjectMutationInputSchema = z.object({
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
export type TProjectMutationInputSchema = z.infer<typeof ZProjectMutationInputSchema>;
|
|
@ -0,0 +1,39 @@
|
|||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import type { BasecampToken } from "../lib/CalendarService";
|
||||
import { refreshAccessToken } from "../lib/helpers";
|
||||
|
||||
interface ProjectsHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
}
|
||||
|
||||
export const projectHandler = async ({ ctx }: ProjectsHandlerOptions) => {
|
||||
const { user_agent } = await getAppKeysFromSlug("basecamp3");
|
||||
const { user, prisma } = ctx;
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
});
|
||||
if (!credential) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "No credential found for user" });
|
||||
}
|
||||
let credentialKey = credential.key as BasecampToken;
|
||||
if (credentialKey.expires_at < Date.now()) {
|
||||
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
|
||||
}
|
||||
|
||||
const url = `${credentialKey.account.href}/projects.json`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
headers: { "User-Agent": user_agent as string, Authorization: `Bearer ${credentialKey.access_token}` },
|
||||
});
|
||||
const projects = await resp.json();
|
||||
return { currentProject: credentialKey.projectId, projects };
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(z.object({}));
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
user_agent: z.string().min(1),
|
||||
});
|
|
@ -32,6 +32,7 @@ const appStore = {
|
|||
facetime: () => import("./facetime"),
|
||||
sylapsvideo: () => import("./sylapsvideo"),
|
||||
"zoho-bigin": () => import("./zoho-bigin"),
|
||||
basecamp3: () => import("./basecamp3"),
|
||||
telegramvideo: () => import("./telegram"),
|
||||
};
|
||||
|
||||
|
|
|
@ -226,6 +226,17 @@ async function createApp(
|
|||
export default async function main() {
|
||||
// Calendar apps
|
||||
await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar");
|
||||
if (
|
||||
process.env.BASECAMP3_CLIENT_ID &&
|
||||
process.env.BASECAMP3_CLIENT_SECRET &&
|
||||
process.env.BASECAMP3_USER_AGENT
|
||||
) {
|
||||
await createApp("basecamp3", "basecamp3", ["other"], "basecamp3_other", {
|
||||
client_id: process.env.BASECAMP3_CLIENT_ID,
|
||||
client_secret: process.env.BASECAMP3_CLIENT_SECRET,
|
||||
user_agent: process.env.BASECAMP3_USER_AGENT,
|
||||
});
|
||||
}
|
||||
await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar");
|
||||
try {
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(
|
||||
|
|
|
@ -23,6 +23,7 @@ const ENDPOINTS = [
|
|||
"apps",
|
||||
"auth",
|
||||
"availability",
|
||||
"appBasecamp3",
|
||||
"bookings",
|
||||
"deploymentSetup",
|
||||
"eventTypes",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import app_Basecamp3 from "@calcom/app-store/basecamp3/trpc-router";
|
||||
import app_RoutingForms from "@calcom/app-store/routing-forms/trpc-router";
|
||||
import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router";
|
||||
import { featureFlagRouter } from "@calcom/features/flags/server/router";
|
||||
|
@ -46,6 +47,7 @@ export const viewerRouter = mergeRouters(
|
|||
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
|
||||
// After that there would just one merge call here for all the apps.
|
||||
appRoutingForms: app_RoutingForms,
|
||||
appBasecamp3: app_Basecamp3,
|
||||
features: featureFlagRouter,
|
||||
appsRouter,
|
||||
users: userAdminRouter,
|
||||
|
|
|
@ -180,6 +180,9 @@
|
|||
"ANALYZE",
|
||||
"API_KEY_PREFIX",
|
||||
"APP_USER_NAME",
|
||||
"BASECAMP3_CLIENT_ID",
|
||||
"BASECAMP3_CLIENT_SECRET",
|
||||
"BASECAMP3_USER_AGENT",
|
||||
"AUTH_BEARER_TOKEN_VERCEL",
|
||||
"BUILD_ID",
|
||||
"CALCOM_ENV",
|
||||
|
|
Loading…
Reference in New Issue
Block a user