From e5283116dbbbcbd0f56ab2922d608ed30d7c42ec Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:36:06 -0500 Subject: [PATCH] feat: add Google Drive integration via Composio - Add generic Composio toolkit infrastructure (toolkits.ts, authenticateToolkit.ts, getConnectedAccount.ts, refreshConnectedAccount.ts) - Add /api/connectedAccounts/googleDrive/refresh endpoint - Refactor Google Sheets to use shared Composio utilities (DRY) - Add /api/connectedAccounts/googleSheets/refresh endpoint (moved from generic /refresh) --- .../{ => googleDrive}/refresh/route.ts | 35 ++++----- .../googleSheets/refresh/route.ts | 50 +++++++++++++ lib/composio/authenticateToolkit.ts | 28 ++++++++ lib/composio/getConnectedAccount.ts | 37 ++++++++++ .../authenticateGoogleSheetsToolkit.ts | 29 -------- .../googleSheets/getConnectedAccount.ts | 33 --------- .../googleSheets/refreshConnectedAccount.ts | 60 ---------------- lib/composio/refreshConnectedAccount.ts | 72 +++++++++++++++++++ lib/composio/toolkits.ts | 44 ++++++++++++ 9 files changed, 243 insertions(+), 145 deletions(-) rename app/api/connectedAccounts/{ => googleDrive}/refresh/route.ts (52%) create mode 100644 app/api/connectedAccounts/googleSheets/refresh/route.ts create mode 100644 lib/composio/authenticateToolkit.ts create mode 100644 lib/composio/getConnectedAccount.ts delete mode 100644 lib/composio/googleSheets/authenticateGoogleSheetsToolkit.ts delete mode 100644 lib/composio/googleSheets/getConnectedAccount.ts delete mode 100644 lib/composio/googleSheets/refreshConnectedAccount.ts create mode 100644 lib/composio/refreshConnectedAccount.ts create mode 100644 lib/composio/toolkits.ts diff --git a/app/api/connectedAccounts/refresh/route.ts b/app/api/connectedAccounts/googleDrive/refresh/route.ts similarity index 52% rename from app/api/connectedAccounts/refresh/route.ts rename to app/api/connectedAccounts/googleDrive/refresh/route.ts index e2e7082..a2bccc3 100644 --- a/app/api/connectedAccounts/refresh/route.ts +++ b/app/api/connectedAccounts/googleDrive/refresh/route.ts @@ -1,11 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import refreshConnectedAccount from "@/lib/composio/googleSheets/refreshConnectedAccount"; +import { refreshConnectedAccount } from "@/lib/composio/refreshConnectedAccount"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; /** * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. */ export async function OPTIONS() { return new NextResponse(null, { @@ -15,10 +13,7 @@ export async function OPTIONS() { } /** - * POST handler for refreshing a connected account. - * - * @param request - The request object. - * @returns A NextResponse with the refreshed connected account. + * POST handler for refreshing a Google Drive connected account. */ export async function POST(request: NextRequest) { try { @@ -28,34 +23,28 @@ export async function POST(request: NextRequest) { if (!accountId) { return NextResponse.json( { error: "accountId is required" }, - { - status: 400, - headers: getCorsHeaders(), - }, + { status: 400, headers: getCorsHeaders() }, ); } - const response = await refreshConnectedAccount(accountId, redirectUrl); + const response = await refreshConnectedAccount( + "GOOGLE_DRIVE", + accountId, + redirectUrl, + ); return NextResponse.json( { message: "Connected account refreshed successfully", ...response }, - { - status: 200, - headers: getCorsHeaders(), - }, + { status: 200, headers: getCorsHeaders() }, ); } catch (error) { - console.error("Error refreshing connected account:", error); - - const errorMessage = error instanceof Error ? error.message : "Internal server error"; + const errorMessage = + error instanceof Error ? error.message : "Internal server error"; const statusCode = errorMessage.includes("not found") ? 404 : 500; return NextResponse.json( { error: errorMessage }, - { - status: statusCode, - headers: getCorsHeaders(), - }, + { status: statusCode, headers: getCorsHeaders() }, ); } } diff --git a/app/api/connectedAccounts/googleSheets/refresh/route.ts b/app/api/connectedAccounts/googleSheets/refresh/route.ts new file mode 100644 index 0000000..4b6fb47 --- /dev/null +++ b/app/api/connectedAccounts/googleSheets/refresh/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { refreshConnectedAccount } from "@/lib/composio/refreshConnectedAccount"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST handler for refreshing a Google Sheets connected account. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { accountId, redirectUrl } = body; + + if (!accountId) { + return NextResponse.json( + { error: "accountId is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const response = await refreshConnectedAccount( + "GOOGLE_SHEETS", + accountId, + redirectUrl, + ); + + return NextResponse.json( + { message: "Connected account refreshed successfully", ...response }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Internal server error"; + const statusCode = errorMessage.includes("not found") ? 404 : 500; + + return NextResponse.json( + { error: errorMessage }, + { status: statusCode, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/composio/authenticateToolkit.ts b/lib/composio/authenticateToolkit.ts new file mode 100644 index 0000000..3a40e4b --- /dev/null +++ b/lib/composio/authenticateToolkit.ts @@ -0,0 +1,28 @@ +import { CreateConnectedAccountOptions } from "@composio/core"; +import { getComposioClient } from "./client"; +import { ComposioToolkitKey, getToolkitAuthConfigId } from "./toolkits"; + +/** + * Initiate OAuth authentication for a Composio toolkit. + * + * @param toolkitKey - The toolkit to authenticate (e.g., "GOOGLE_SHEETS", "GOOGLE_DRIVE") + * @param userId - The user ID to authenticate + * @param options - Optional callback URL and other options + * @returns The connection request from Composio + */ +export async function authenticateToolkit( + toolkitKey: ComposioToolkitKey, + userId: string, + options?: CreateConnectedAccountOptions, +) { + const authConfigId = getToolkitAuthConfigId(toolkitKey); + const composio = getComposioClient(); + + const connectionRequest = await composio.connectedAccounts.initiate( + userId, + authConfigId, + options, + ); + + return connectionRequest; +} diff --git a/lib/composio/getConnectedAccount.ts b/lib/composio/getConnectedAccount.ts new file mode 100644 index 0000000..2479e41 --- /dev/null +++ b/lib/composio/getConnectedAccount.ts @@ -0,0 +1,37 @@ +import { CreateConnectedAccountOptions } from "@composio/core"; +import { getComposioClient } from "./client"; +import { authenticateToolkit } from "./authenticateToolkit"; +import { ComposioToolkitKey, getToolkitConfig } from "./toolkits"; + +/** + * Get a user's connected account for a Composio toolkit. + * If no connection exists, initiates the authentication flow. + * + * @param toolkitKey - The toolkit to check (e.g., "GOOGLE_SHEETS", "GOOGLE_DRIVE") + * @param accountId - The user's account ID + * @param options - Optional callback URL and other options + * @returns The user's connected accounts for this toolkit + */ +export async function getConnectedAccount( + toolkitKey: ComposioToolkitKey, + accountId: string, + options?: CreateConnectedAccountOptions, +) { + const config = getToolkitConfig(toolkitKey); + const composio = getComposioClient(); + + let userAccounts = await composio.connectedAccounts.list({ + userIds: [accountId], + toolkitSlugs: [config.slug], + }); + + if (userAccounts.items.length === 0) { + await authenticateToolkit(toolkitKey, accountId, options); + userAccounts = await composio.connectedAccounts.list({ + userIds: [accountId], + toolkitSlugs: [config.slug], + }); + } + + return userAccounts; +} diff --git a/lib/composio/googleSheets/authenticateGoogleSheetsToolkit.ts b/lib/composio/googleSheets/authenticateGoogleSheetsToolkit.ts deleted file mode 100644 index 3236b06..0000000 --- a/lib/composio/googleSheets/authenticateGoogleSheetsToolkit.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { CreateConnectedAccountOptions } from "@composio/core"; -import { getComposioClient } from "../client"; - -const googleSheetsAuthConfigId = process.env?.COMPOSIO_GOOGLE_SHEETS_AUTH_CONFIG_ID as string; -if (!googleSheetsAuthConfigId) { - throw new Error("COMPOSIO_GOOGLE_SHEETS_AUTH_CONFIG_ID not found in environment variables"); -} - -/** - * Authenticate a user with the Google Sheets toolkit. - * - * @param userId - The user ID to authenticate. - * @param options - The options for the authentication. - * @returns The connection request. - */ -async function authenticateGoogleSheetsToolkit( - userId: string, - options?: CreateConnectedAccountOptions, -) { - const composio = getComposioClient(); - const connectionRequest = await composio.connectedAccounts.initiate( - userId, - googleSheetsAuthConfigId, - options, - ); - return connectionRequest; -} - -export default authenticateGoogleSheetsToolkit; diff --git a/lib/composio/googleSheets/getConnectedAccount.ts b/lib/composio/googleSheets/getConnectedAccount.ts deleted file mode 100644 index f9847d5..0000000 --- a/lib/composio/googleSheets/getConnectedAccount.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getComposioClient } from "@/lib/composio/client"; -import authenticateGoogleSheetsToolkit from "./authenticateGoogleSheetsToolkit"; -import { CreateConnectedAccountOptions } from "@composio/core"; - -export const GOOGLE_SHEETS_TOOLKIT_SLUG = "GOOGLESHEETS"; - -/** - * Get a connected account. - * - * @param accountId - The ID of the connected account to get. - * @param options - The options for the authentication. - * @returns The connected account. - */ -export default async function getConnectedAccount( - accountId: string, - options?: CreateConnectedAccountOptions, -) { - const composio = getComposioClient(); - let userAccounts = await composio.connectedAccounts.list({ - userIds: [accountId], - toolkitSlugs: [GOOGLE_SHEETS_TOOLKIT_SLUG], - }); - - if (userAccounts.items.length === 0) { - await authenticateGoogleSheetsToolkit(accountId, options); - userAccounts = await composio.connectedAccounts.list({ - userIds: [accountId], - toolkitSlugs: [GOOGLE_SHEETS_TOOLKIT_SLUG], - }); - } - - return userAccounts; -} diff --git a/lib/composio/googleSheets/refreshConnectedAccount.ts b/lib/composio/googleSheets/refreshConnectedAccount.ts deleted file mode 100644 index 91ab213..0000000 --- a/lib/composio/googleSheets/refreshConnectedAccount.ts +++ /dev/null @@ -1,60 +0,0 @@ -import getConnectedAccount from "./getConnectedAccount"; -import { getComposioApiKey } from "../getComposioApiKey"; - -export interface ConnectedAccountRefreshResponse { - id: string; - redirect_url: string | null; - status: "INITIALIZING" | "INITIATED" | "ACTIVE" | "FAILED" | "EXPIRED" | "INACTIVE"; -} - -const COMPOSIO_API_BASE_URL = "https://backend.composio.dev"; - -/** - * Refresh a connected account. - * - * @param accountId - The ID of the connected account to refresh. - * @param redirectUrl - The URL to redirect to after the refresh. - * @returns The refreshed connected account. - */ -export default async function refreshConnectedAccount( - accountId: string, - redirectUrl?: string, -): Promise { - const apiKey = getComposioApiKey(); - const accounts = await getConnectedAccount(accountId); - - if (!accounts.items || accounts.items.length === 0) { - throw new Error("Connected account not found"); - } - - const connectedAccountId = accounts.items[0].id; - - if (!connectedAccountId) { - throw new Error("Connected account ID not found"); - } - - const url = new URL( - `${COMPOSIO_API_BASE_URL}/api/v3/connected_accounts/${connectedAccountId}/refresh`, - ); - - const body: { redirect_url?: string } = {}; - if (redirectUrl) { - body.redirect_url = redirectUrl; - } - - const response = await fetch(url.toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to refresh connected account: ${response.status} ${errorText}`); - } - - return await response.json(); -} diff --git a/lib/composio/refreshConnectedAccount.ts b/lib/composio/refreshConnectedAccount.ts new file mode 100644 index 0000000..af77203 --- /dev/null +++ b/lib/composio/refreshConnectedAccount.ts @@ -0,0 +1,72 @@ +import { getConnectedAccount } from "./getConnectedAccount"; +import { getComposioApiKey } from "./getComposioApiKey"; +import { ComposioToolkitKey, getToolkitConfig } from "./toolkits"; + +export interface ConnectedAccountRefreshResponse { + id: string; + redirect_url: string | null; + status: + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE"; +} + +const COMPOSIO_API_BASE_URL = "https://backend.composio.dev"; + +/** + * Refresh a connected account for a Composio toolkit. + * + * @param toolkitKey - The toolkit to refresh (e.g., "GOOGLE_SHEETS", "GOOGLE_DRIVE") + * @param accountId - The user's account ID + * @param redirectUrl - The URL to redirect to after OAuth + * @returns The refresh response with OAuth redirect URL + */ +export async function refreshConnectedAccount( + toolkitKey: ComposioToolkitKey, + accountId: string, + redirectUrl?: string, +): Promise { + const apiKey = getComposioApiKey(); + const config = getToolkitConfig(toolkitKey); + const accounts = await getConnectedAccount(toolkitKey, accountId); + + if (!accounts.items || accounts.items.length === 0) { + throw new Error(`${config.name} connected account not found`); + } + + const connectedAccountId = accounts.items[0].id; + + if (!connectedAccountId) { + throw new Error(`${config.name} connected account ID not found`); + } + + const url = new URL( + `${COMPOSIO_API_BASE_URL}/api/v3/connected_accounts/${connectedAccountId}/refresh`, + ); + + const body: { redirect_url?: string } = {}; + if (redirectUrl) { + body.redirect_url = redirectUrl; + } + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to refresh ${config.name} connected account: ${response.status} ${errorText}`, + ); + } + + return await response.json(); +} diff --git a/lib/composio/toolkits.ts b/lib/composio/toolkits.ts new file mode 100644 index 0000000..9142dfc --- /dev/null +++ b/lib/composio/toolkits.ts @@ -0,0 +1,44 @@ +/** + * Configuration for Composio toolkits. + * Add new toolkits here to enable them across the application. + * + * This is the single source of truth for all Composio toolkit configurations. + */ +export const COMPOSIO_TOOLKITS = { + GOOGLE_SHEETS: { + slug: "GOOGLESHEETS", + name: "Google Sheets", + authConfigEnvVar: "COMPOSIO_GOOGLE_SHEETS_AUTH_CONFIG_ID", + }, + GOOGLE_DRIVE: { + slug: "GOOGLEDRIVE", + name: "Google Drive", + authConfigEnvVar: "COMPOSIO_GOOGLE_DRIVE_AUTH_CONFIG_ID", + }, +} as const; + +export type ComposioToolkitKey = keyof typeof COMPOSIO_TOOLKITS; +export type ComposioToolkit = (typeof COMPOSIO_TOOLKITS)[ComposioToolkitKey]; + +/** + * Get toolkit configuration by key. + */ +export function getToolkitConfig(key: ComposioToolkitKey): ComposioToolkit { + return COMPOSIO_TOOLKITS[key]; +} + +/** + * Get the auth config ID for a toolkit from environment variables. + */ +export function getToolkitAuthConfigId(key: ComposioToolkitKey): string { + const config = COMPOSIO_TOOLKITS[key]; + const authConfigId = process.env[config.authConfigEnvVar]; + + if (!authConfigId) { + throw new Error( + `${config.authConfigEnvVar} not found in environment variables` + ); + } + + return authConfigId; +}