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:
Jon@1599 2023-08-20 01:34:56 +05:30 committed by GitHub
parent 3f273235a6
commit f80e9b2558
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 751 additions and 0 deletions

View File

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

View File

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

View File

@ -0,0 +1,4 @@
import appBasecamp3 from "@calcom/app-store/basecamp3/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(appBasecamp3);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
---
items:
- 1.png
- 2.png
- 3.png
---
{DESCRIPTION}

View File

@ -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) }),
});

View File

@ -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 }));
}

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";

View File

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

View File

@ -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. Its 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"
}

View File

@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";

View File

@ -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([]);
}
}

View File

@ -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),
});

View File

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

View File

@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default } from "./trpc/_router";

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZProjectMutationInputSchema = z.object({
projectId: z.string(),
});
export type TProjectMutationInputSchema = z.infer<typeof ZProjectMutationInputSchema>;

View File

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

View File

@ -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),
});

View File

@ -32,6 +32,7 @@ const appStore = {
facetime: () => import("./facetime"),
sylapsvideo: () => import("./sylapsvideo"),
"zoho-bigin": () => import("./zoho-bigin"),
basecamp3: () => import("./basecamp3"),
telegramvideo: () => import("./telegram"),
};

View File

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

View File

@ -23,6 +23,7 @@ const ENDPOINTS = [
"apps",
"auth",
"availability",
"appBasecamp3",
"bookings",
"deploymentSetup",
"eventTypes",

View File

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

View File

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