diff --git a/.gitignore b/.gitignore index 7274fa8..e38f809 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ packages/common/tsconfig.tsbuildinfo packages/db/tsconfig.tsbuildinfo apps/http-backend/tsconfig.tsbuildinfo packages/nodes/tsconfig.tsbuildinfo +.agents/rules/code-mentor.md diff --git a/apps/http-backend/src/routes/google_callback.ts b/apps/http-backend/src/routes/google_callback.ts index 7b31781..11daa2f 100644 --- a/apps/http-backend/src/routes/google_callback.ts +++ b/apps/http-backend/src/routes/google_callback.ts @@ -20,6 +20,7 @@ dotenv.config({ path: envPath, override: true }); // Log OAuth configuration at module load const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || "http://localhost:3002/oauth/google/callback"; +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; console.log("šŸ” OAuth Configuration loaded:"); console.log(" .env path:", envPath); console.log(" GOOGLE_REDIRECT_URI from env:", process.env.GOOGLE_REDIRECT_URI || "NOT SET (using default)"); @@ -35,34 +36,23 @@ googleAuth.get( // const userId = req.user?.id || "test_user"; // Get from auth middleware const userId = req.user?.id; const workflowId = req.query.workflowId as string | undefined; + const authType = req.query.authType as string; // Ensure redirect URI matches Google Cloud Console configuration - const redirectUri = REDIRECT_URI; - + const redirectUri = (req.query.redirect_url as string) || (workflowId ? `/workflows/${workflowId}` : '/workflows'); + console.log(` redirect from google calback before auth: ${redirectUri}`) + if (!authType) return res.status(statusCodes.BAD_REQUEST).json({ + error: "Auth Type is required", + success: false, + }) + console.log("šŸ” OAuth Initiate - Redirect URI:", redirectUri); console.log("šŸ” OAuth Initiate - User ID:", userId); console.log("šŸ” OAuth Initiate - Workflow ID:", workflowId || "NOT PROVIDED"); - // Encode userId and workflowId in state (format: userId|workflowId) - const state = workflowId ? `${userId}|${workflowId}` : userId; - - const oauth2 = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - redirectUri - ); - const authUrl = oauth2.generateAuthUrl({ - access_type: "offline", - scope: [ - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.readonly", - ], - state: state, - prompt: "consent", - }); + const authService = new GoogleOAuthService(); + const authUrl = authService.getAuthUrl(userId!, authType, redirectUri) console.log("šŸ” OAuth Initiate - Generated Auth URL:", authUrl); return res.redirect(authUrl); @@ -76,163 +66,135 @@ googleAuth.get( console.log("Request recieved to the callback from fronted ") const code = req.query.code; const state = req.query.state; - const Oauth = new GoogleOAuthService(); if (!code || typeof code !== "string") { return res.json({ error: "Missing or invalid authorization code" }); } - // Ensure redirect URI matches Google Cloud Console configuration - const redirectUri = REDIRECT_URI; - - // Parse state: format is "userId" or "userId|workflowId" - let userId: string | undefined; - let workflowId: string | undefined; - - if (state && typeof state === "string") { - const parts = state.split("|"); - userId = parts[0]; - workflowId = parts[1]; // Will be undefined if not provided - } - - console.log("šŸ” OAuth Callback - Redirect URI:", redirectUri); + const authService = new GoogleOAuthService() + + const { userId, type, redirect_uri } = JSON.parse(Buffer.from(state as string, 'base64').toString()) + console.log("šŸ” OAuth Callback - Redirect URI:", FRONTEND_URL + redirect_uri); console.log("šŸ” OAuth Callback - Received code:", code ? "āœ… Present" : "āŒ Missing"); - console.log("šŸ” OAuth Callback - State:", state); - console.log("šŸ” OAuth Callback - Parsed User ID:", userId); - console.log("šŸ” OAuth Callback - Parsed Workflow ID:", workflowId || "NOT PROVIDED"); - const oauth2 = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - redirectUri - ); try { - const { tokens } = await oauth2.getToken(code); - - // DEBUG: Log tokens received from Google - console.log('\nšŸ” Google OAuth Callback - Tokens received:'); - console.log(' access_token:', tokens.access_token ? 'āœ… Present' : 'āŒ Missing'); - console.log(' refresh_token:', tokens.refresh_token ? 'āœ… Present' : 'āŒ Missing'); - console.log(' expiry_date:', tokens.expiry_date); - console.log(' token_type:', tokens.token_type); - console.log(' scope:', tokens.scope); - - if (!tokens.refresh_token) { - console.warn('āš ļø WARNING: No refresh_token received! User may have already authorized this app.'); - console.warn(' To force new refresh_token, user needs to revoke access at: https://myaccount.google.com/permissions'); - } - - // Save tokens to database if userId is provided - if (userId) { - console.log(' Saving tokens for userId:', userId); - await Oauth.saveCredentials(userId, tokens as OAuthTokens) - console.log(' āœ… Tokens saved to database'); - } + const tokens = await authService.getTokens(code); + + // DEBUG: Log tokens received from Google + console.log('\nšŸ” Google OAuth Callback - Tokens received:'); + console.log(' access_token:', tokens.access_token ? 'āœ… Present' : 'āŒ Missing'); + console.log(' refresh_token:', tokens.refresh_token ? 'āœ… Present' : 'āŒ Missing'); + console.log(' expiry_date:', tokens.expiry_date); + console.log(' token_type:', tokens.token_type); + console.log(' scope:', tokens.scope); + + if (!tokens.refresh_token) { + console.warn('āš ļø WARNING: No refresh_token received! User may have already authorized this app.'); + console.warn(' To force new refresh_token, user needs to revoke access at: https://myaccount.google.com/permissions'); + } + + // Save tokens to database if userId is provided + if (userId) { + console.log(' Saving tokens for userId:', userId); + await authService.saveCredentials(userId, tokens as OAuthTokens, type) + console.log(' āœ… Tokens saved to database'); + } // Redirect to workflow page if workflowId is provided, otherwise to general workflow page - const redirectUrl = workflowId - ? `http://localhost:3000/workflows/${workflowId}` - : "http://localhost:3000/workflows"; - console.log(' Redirecting to:', redirectUrl); - return res.redirect(redirectUrl); + + console.log(' Redirecting to:', redirect_uri); + return res.redirect(`${FRONTEND_URL}${redirect_uri}`); } catch (err: any) { console.error("Google token exchange error:", err); // Parse state to get workflowId for error redirect - let workflowId: string | undefined; - if (state && typeof state === "string") { - const parts = state.split("|"); - workflowId = parts[1]; - } - const errorUrl = workflowId - ? `http://localhost:3000/workflows/${workflowId}?google=error&msg=${encodeURIComponent(err?.message ?? "Token exchange failed")}` - : `http://localhost:3000/workflow?google=error&msg=${encodeURIComponent(err?.message ?? "Token exchange failed")}`; - return res.redirect(errorUrl); + const { redirect_uri } = JSON.parse(Buffer.from(state as string, 'base64').toString()); + return res.redirect(`${FRONTEND_URL}${redirect_uri}`); } -}) + }) // Debug endpoint to check OAuth configuration -googleAuth.get('/debug/config', async(req: Request, res: Response)=>{ - try { - const redirectUri = REDIRECT_URI; - const clientId = process.env.GOOGLE_CLIENT_ID; - const hasClientSecret = !!process.env.GOOGLE_CLIENT_SECRET; - - // Create a test OAuth2 client to verify configuration - const oauth2 = new google.auth.OAuth2( - clientId, - process.env.GOOGLE_CLIENT_SECRET, - redirectUri - ); - - const testAuthUrl = oauth2.generateAuthUrl({ - access_type: "offline", - scope: ["https://www.googleapis.com/auth/spreadsheets"], - state: "test", - prompt: "consent", - }); - - const urlObj = new URL(testAuthUrl); - const redirectUriInUrl = urlObj.searchParams.get('redirect_uri'); - - return res.json({ - environment: { - GOOGLE_REDIRECT_URI: redirectUri, - GOOGLE_CLIENT_ID: clientId ? `${clientId.substring(0, 20)}...` : "NOT SET", - GOOGLE_CLIENT_SECRET: hasClientSecret ? "SET" : "NOT SET", - }, - oauth2Client: { - redirectUri: redirectUri, - redirectUriInGeneratedUrl: redirectUriInUrl, - matches: redirectUri === redirectUriInUrl, - }, - testAuthUrl: testAuthUrl, - message: redirectUri === redirectUriInUrl - ? "āœ… Configuration looks correct!" - : "āŒ Redirect URI mismatch detected!" - }); - } catch (err) { - return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ - error: err instanceof Error ? err.message : 'Unknown error', - stack: err instanceof Error ? err.stack : undefined - }); - } +googleAuth.get('/debug/config', async (req: Request, res: Response) => { + try { + const redirectUri = REDIRECT_URI; + const clientId = process.env.GOOGLE_CLIENT_ID; + const hasClientSecret = !!process.env.GOOGLE_CLIENT_SECRET; + + // Create a test OAuth2 client to verify configuration + const oauth2 = new google.auth.OAuth2( + clientId, + process.env.GOOGLE_CLIENT_SECRET, + redirectUri + ); + + const testAuthUrl = oauth2.generateAuthUrl({ + access_type: "offline", + scope: ["https://www.googleapis.com/auth/spreadsheets"], + state: "test", + prompt: "consent", + }); + + const urlObj = new URL(testAuthUrl); + const redirectUriInUrl = urlObj.searchParams.get('redirect_uri'); + + return res.json({ + environment: { + GOOGLE_REDIRECT_URI: redirectUri, + GOOGLE_CLIENT_ID: clientId ? `${clientId.substring(0, 20)}...` : "NOT SET", + GOOGLE_CLIENT_SECRET: hasClientSecret ? "SET" : "NOT SET", + }, + oauth2Client: { + redirectUri: redirectUri, + redirectUriInGeneratedUrl: redirectUriInUrl, + matches: redirectUri === redirectUriInUrl, + }, + testAuthUrl: testAuthUrl, + message: redirectUri === redirectUriInUrl + ? "āœ… Configuration looks correct!" + : "āŒ Redirect URI mismatch detected!" + }); + } catch (err) { + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + error: err instanceof Error ? err.message : 'Unknown error', + stack: err instanceof Error ? err.stack : undefined + }); + } }); // Debug endpoint to check stored credentials -googleAuth.get('/debug/credentials', async(req: Request, res: Response)=>{ - try { - const { prismaClient } = await import('@repo/db/client'); - - const credentials = await prismaClient.credential.findMany({ - where: { type: 'google_oauth' }, - select: { - id: true, - userId: true, - type: true, - config: true - } - }); - - const debugInfo = credentials.map(cred => { - const config = cred.config as any; - return { - id: cred.id, - userId: cred.userId, - hasAccessToken: !!config?.access_token, - hasRefreshToken: !!config?.refresh_token, - refreshTokenLength: config?.refresh_token?.length || 0, - expiryDate: config?.expiry_date, - expiresIn: config?.expiry_date ? Math.round((config.expiry_date - Date.now()) / 1000 / 60) + ' minutes' : 'N/A', - isInvalid: config?.invalid || false, - scope: config?.scope - }; - }); - - console.log('\nšŸ“‹ Stored Credentials Debug:'); - console.table(debugInfo); - - return res.json({ credentials: debugInfo }); - } catch (err) { - return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ error: err instanceof Error ? err.message : 'Unknown error' }); - } +googleAuth.get('/debug/credentials', async (req: Request, res: Response) => { + try { + const { prismaClient } = await import('@repo/db/client'); + + const credentials = await prismaClient.credential.findMany({ + where: { type: { in: ['gmail_oauth', 'gsheet_oauth', 'google_oauth'] } }, + select: { + id: true, + userId: true, + type: true, + config: true + } + }); + + const debugInfo = credentials.map(cred => { + const config = cred.config as any; + return { + id: cred.id, + userId: cred.userId, + hasAccessToken: !!config?.access_token, + hasRefreshToken: !!config?.refresh_token, + refreshTokenLength: config?.refresh_token?.length || 0, + expiryDate: config?.expiry_date, + expiresIn: config?.expiry_date ? Math.round((config.expiry_date - Date.now()) / 1000 / 60) + ' minutes' : 'N/A', + isInvalid: config?.invalid || false, + scope: config?.scope + }; + }); + + console.log('\nšŸ“‹ Stored Credentials Debug:'); + console.table(debugInfo); + + return res.json({ credentials: debugInfo }); + } catch (err) { + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ error: err instanceof Error ? err.message : 'Unknown error' }); + } }); diff --git a/apps/http-backend/src/routes/sheet.routes.ts b/apps/http-backend/src/routes/sheet.routes.ts index 3b00c25..6c0836a 100644 --- a/apps/http-backend/src/routes/sheet.routes.ts +++ b/apps/http-backend/src/routes/sheet.routes.ts @@ -30,6 +30,7 @@ sheetRouter.get( const sheets = await sheetExecutor.getSheets({ userId: userId, credentialId: credentialId, + authType: 'gsheet_oauth' }); if ((sheets as any)?.success === false) { return res.status(statusCodes.NOT_FOUND).json({ @@ -76,7 +77,7 @@ sheetRouter.get( }); } const sheets = await sheetExecutor.getSheetTabs( - { userId: userId, credentialId: credentialId }, + { userId: userId, credentialId: credentialId, authType: 'gsheet_oauth' }, sheetId ); @@ -101,3 +102,46 @@ sheetRouter.get( } } ); + +sheetRouter.get( + "/getHeaders/:cred/:sheetId/:sheetName", + userMiddleware, + async (req: AuthRequest, res: Response) => { + try { + const userId = req.user?.sub; + if (!userId) + return res.status(statusCodes.UNAUTHORIZED).json({ + message: "User not authorized" + }) + + const { cred: credentialId, sheetId, sheetName } = req.params; + + if (!credentialId || !sheetId || !sheetName) { + return res.status(statusCodes.BAD_REQUEST).json({ + message: "Missing required parameters" + }) + } + + const result = await sheetExecutor.getHeaderRow({ + userId, credentialId, authType: 'gsheet_oauth' + }, sheetId, sheetName + ) + + if (!result.success) + return res.status(statusCodes.NOT_FOUND).json({ + message: "Failed to fetch headers", error: result + }); + + return res.status(statusCodes.OK).json({ + message: "Headers fetched successfully", + headers: result.output + }); + } + catch (e) { + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + message: "Error fetching sheet headers", + error: e instanceof Error ? e.message : "Unknown error" + }); + } + } +) diff --git a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts index b3687cd..ed00904 100644 --- a/apps/http-backend/src/routes/userRoutes/executionRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/executionRoutes.ts @@ -1,53 +1,54 @@ import { Response, Request, Router } from "express"; -import { AuthRequest, userMiddleware } from "./userMiddleware.js"; +import { AuthRequest, userMiddleware } from "./userMiddleware.js"; import { ExecuteNode, statusCodes } from "@repo/common/zod"; import { prismaClient } from "@repo/db"; import { ExecutionRegister } from '@repo/nodes' export const execRouter: Router = Router() -execRouter.post('/node', userMiddleware, async(req: AuthRequest, res: Response)=>{ - try{ - if(!req.user?.sub){ +execRouter.post('/node', userMiddleware, async (req: AuthRequest, res: Response) => { + try { + if (!req.user?.sub) { return res.status(statusCodes.BAD_REQUEST).json({ - message: "User is not logged in ", - }); + message: "User is not logged in ", + }); } const data = req.body; const dataSafe = ExecuteNode.safeParse(data) - if(!dataSafe.success){ + if (!dataSafe.success) { return res.status(statusCodes.BAD_REQUEST).json({ message: "Invalid input" }) } const nodeData = await prismaClient.node.findFirst({ - where: {id: dataSafe.data.NodeId}, + where: { id: dataSafe.data.NodeId }, include: { AvailableNode: true } }) - if(nodeData){ + if (nodeData) { const type = nodeData.AvailableNode.type const config = dataSafe.data.Config ? dataSafe.data.Config : nodeData.config // for test api data prefered fist then config in db // // console.log(`config and type: ${JSON.stringify(config)} & ${type}`) - + const context = { userId: req.user.sub, config: config, - credentialId: nodeData.CredentialsID || config?.credentialId || "" + credentialId: nodeData.CredentialsID || config?.credentialId || "", + authType: nodeData.AvailableNode.authType } - const result = await prismaClient.$transaction(async(tx)=>{ + const result = await prismaClient.$transaction(async (tx) => { // // console.log(`Execution context: ${JSON.stringify(context)}`) const workflowExecution = await tx.workflowExecution.create({ - data:{ + data: { workflowId: nodeData.workflowId || "", status: "Start", startAt: new Date(), - metadata:{"isTesting": true}, + metadata: { "isTesting": true }, } }) const NodeExecution = await tx.nodeExecution.create({ - data:{ + data: { status: "Start", nodeId: nodeData.id, workflowExecId: workflowExecution.id, @@ -57,61 +58,67 @@ execRouter.post('/node', userMiddleware, async(req: AuthRequest, res: Response) } }) const executionResult = await ExecutionRegister.execute(type, context) - - + + // console.log(`Execution result: ${executionResult}`) - - if(executionResult.success){ + + if (executionResult.success) { await tx.nodeExecution.update({ - where: { id: NodeExecution.id}, - data:{ + where: { id: NodeExecution.id }, + data: { status: "Completed", outputData: executionResult.output, completedAt: new Date() } }) await tx.workflowExecution.update({ - where: {id: workflowExecution.id}, + where: { id: workflowExecution.id }, data: { status: "Completed", completedAt: new Date() } }) - return res.status(statusCodes.ACCEPTED).json({ - message: `${nodeData.name} node execution done` , - data: executionResult - }) + return { success: true, executionResult } } await tx.nodeExecution.update({ - where: {id : NodeExecution.id}, - data:{ + where: { id: NodeExecution.id }, + data: { status: "Failed", completedAt: new Date(), error: executionResult.error } }) await tx.workflowExecution.update({ - where: {id: workflowExecution.id}, - data:{ + where: { id: workflowExecution.id }, + data: { status: "Failed", completedAt: new Date(), error: executionResult.output } }) + return { success: false, executionResult } }) - return res.status(statusCodes.FORBIDDEN).json({ - message: `${nodeData.name} node execution failed` - }) - } + if (result.success) { + return res.status(statusCodes.ACCEPTED).json({ + message: `${nodeData.name} node execution done`, + data: result.executionResult + }); + } else { + return res.status(statusCodes.FORBIDDEN).json({ + message: `${nodeData.name} node execution failed`, + data: result.executionResult + }); + } + } return res.status(statusCodes.NOT_FOUND).json({ message: `${dataSafe.data.NodeId} not found` - }) + }) - }catch(e){ - // console.log("This is the error from executing node", e); - return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ - message: "Internal server Error from node execution ", - }); + } catch (e) { + // console.log("This is the error from executing node", e); + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + message: "Internal server Error from node execution ", + }); } }) \ No newline at end of file diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index a274b5b..4570aa7 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -17,7 +17,7 @@ import { HOOKS_URL, DashboardRangeSchema, } from "@repo/common/zod"; -import { GoogleSheetsNodeExecutor } from "@repo/nodes"; +import { GoogleOAuthService, GoogleSheetsNodeExecutor } from "@repo/nodes"; import axios from "axios"; const router: Router = Router(); @@ -187,7 +187,7 @@ router.get("/getCredentials/:type", message: "Incorrect type Input", }); } - const exec = new GoogleSheetsNodeExecutor() + const authService = new GoogleOAuthService() // Check if credentials exist in database // const credentials = await prismaClient.credential.findMany({ @@ -196,7 +196,7 @@ router.get("/getCredentials/:type", // type: type, // }, // }); - const credentials = await exec.getAllCredentials(userId, type) + const credentials = await authService.getAllCredentials(userId, type) // if (credentials.length === 0) { // // No credentials found - return the correct auth URL @@ -213,16 +213,11 @@ router.get("/getCredentials/:type", // message: "Credentials Fetched successfully", // Data: credentials, // }); - if (credentials.length === 0) { - return res.status(statusCodes.OK).json({ - message: "No credentials found", - }); - } return res.status(statusCodes.OK).json({ message: "Credentials fetched", data: credentials, - hasCredentials: true, + hasCredentials: credentials.length > 0, }); } catch (e) { console.log( @@ -341,7 +336,7 @@ router.get("/dashboard/overview", const gmailConnected = hasSharedGoogleOAuth || credentialTypes.has("gmail_oauth"); const googleSheetsConnected = - hasSharedGoogleOAuth || credentialTypes.has("google_sheets_oauth"); + hasSharedGoogleOAuth || credentialTypes.has("gsheet_oauth"); return res.status(statusCodes.OK).json({ message: "Dashboard overview fetched successfully", diff --git a/apps/http-backend/src/services/token-refresh.service.ts b/apps/http-backend/src/services/token-refresh.service.ts index 9e3351c..c3ccc4b 100644 --- a/apps/http-backend/src/services/token-refresh.service.ts +++ b/apps/http-backend/src/services/token-refresh.service.ts @@ -59,11 +59,11 @@ class TokenRefreshService { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - + // Handle invalid_grant - refresh token is no longer valid if (errorMessage.includes('invalid_grant')) { console.log(`šŸ—‘ļø Deleting invalid credential ${credentialId} (refresh token expired/revoked)`); - + try { // 1. Clear credId from any Triggers that use this credential const triggers = await prismaClient.trigger.findMany({ @@ -74,7 +74,7 @@ class TokenRefreshService { } } }); - + for (const trigger of triggers) { const config = trigger.config as any; delete config.credId; @@ -94,7 +94,7 @@ class TokenRefreshService { } } }); - + for (const node of nodes) { const config = node.config as any; delete config.credId; @@ -109,21 +109,21 @@ class TokenRefreshService { await prismaClient.credential.delete({ where: { id: credentialId } }); - + console.log(`āœ… Credential ${credentialId} deleted. Cleared from ${triggers.length} triggers and ${nodes.length} nodes.`); console.log(` User needs to reconnect Google account.`); - + } catch (deleteError) { console.error(`Failed to delete credential: ${deleteError}`); } - - return { - credentialId, - success: false, - error: 'Credential deleted. Please reconnect your Google account.' + + return { + credentialId, + success: false, + error: 'Credential deleted. Please reconnect your Google account.' }; } - + console.error(`āŒ Failed to refresh token for credential ${credentialId}: ${errorMessage}`); return { credentialId, success: false, error: errorMessage }; } @@ -134,12 +134,12 @@ class TokenRefreshService { */ async refreshAllExpiringTokens(): Promise<{ total: number; refreshed: number; failed: number; deleted: number }> { console.log('\nšŸ”„ Starting token refresh job...'); - + try { // Fetch all google_oauth credentials const credentials = await prismaClient.credential.findMany({ where: { - type: 'google_oauth' + type: { in: ['gmail_oauth', 'gsheet_oauth', 'google_oauth'] } } }); @@ -162,9 +162,9 @@ class TokenRefreshService { if (this.isTokenExpiring(tokens.expiry_date)) { const expiresIn = Math.round((tokens.expiry_date - Date.now()) / 1000 / 60); console.log(`ā° Credential ${credential.id} expires in ${expiresIn} minutes - refreshing...`); - + const result = await this.refreshToken(credential.id, tokens); - + if (result.success) { refreshed++; } else if (result.error?.includes('deleted')) { @@ -215,10 +215,10 @@ class TokenRefreshService { return await this.refreshToken(credentialId, tokens); } catch (error) { - return { - credentialId, - success: false, - error: error instanceof Error ? error.message : 'Unknown error' + return { + credentialId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' }; } } diff --git a/apps/web/app/hooks/useCredential.ts b/apps/web/app/hooks/useCredential.ts index 56a397a..1bcf43a 100644 --- a/apps/web/app/hooks/useCredential.ts +++ b/apps/web/app/hooks/useCredential.ts @@ -19,12 +19,14 @@ export const useCredentials = (type: string, workflowId?: string): any => { const response = await api.Credentials.getCredentials(type) const data = JSON.stringify(response) - console.log("This is the log from usecredentials" , data) + console.log("This is the log from usecredentials", data) // Backend should ONLY return stored credentials - if (Array.isArray(response)) { - setCred(response); - } else if(typeof response === "string"){ - setAuthUrl(response) + if (response.hasCredentials) { + setCred(response.data); + } else { + console.log("Redirect url:", window.location.pathname) + const url = `${BACKEND_URL}/auth/google/initiate?authType=${type}&redirect_url=${encodeURIComponent(window.location.pathname)}` + setAuthUrl(url) } // Frontend defines where to redirect for OAuth diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts index d41f2c1..4ce935a 100644 --- a/apps/web/app/lib/api.ts +++ b/apps/web/app/lib/api.ts @@ -38,7 +38,7 @@ export const api = { headers: { "Content-Type": "application/json" }, }) }, - getAll: async () =>{ + getAll: async () => { return await axios.get(`${BACKEND_URL}/user/workflows`, { withCredentials: true, @@ -94,7 +94,7 @@ export const api = { withCredentials: true, headers: { "Content-Type": "application/json" }, }); - return res.data.data; + return res.data; }, getAllCreds: async () => @@ -126,31 +126,41 @@ export const api = { }, }, google: { - getDocuments: async (CredentialId : string) => { - const data = await axios.get(`${BACKEND_URL}/node/getDocuments/${CredentialId}`,{ + getDocuments: async (CredentialId: string) => { + const data = await axios.get(`${BACKEND_URL}/node/getDocuments/${CredentialId}`, { withCredentials: true, - headers: {"Content-Type" : "application/json"}, + headers: { "Content-Type": "application/json" }, }) - + console.log(data.data.files) return data.data.files }, getSheets: async (documentId: string, CredentialId: string) => { - const data = await axios.get(`${BACKEND_URL}/node/getSheets/${CredentialId}/${documentId}`,{ + const data = await axios.get(`${BACKEND_URL}/node/getSheets/${CredentialId}/${documentId}`, { withCredentials: true, - headers: {"Content-Type":"application/json"} + headers: { "Content-Type": "application/json" } }) const tabs = data.data.files.data - return tabs.map((tab: any) =>({ + return tabs.map((tab: any) => ({ id: tab.name, name: tab.name })) }, + getHeaders: async (credentialId: string, sheetId: string, sheetName: string) => { + const res = await axios.get( + `${BACKEND_URL}/node/getHeaders/${credentialId}/${sheetId}/${sheetName}`, + { + withCredentials: true, + headers: { 'Content-Type': "application/json" } + } + ); + return res.data.headers + } }, execute: { // Execute a single node for testing node: async (nodeId: string, config?: any) => { - const res = await axios.post(`${BACKEND_URL}/execute/node`, + const res = await axios.post(`${BACKEND_URL}/execute/node`, { NodeId: nodeId, Config: config }, { withCredentials: true, @@ -158,12 +168,12 @@ export const api = { } ); console.log('[api.execute.node] Response:', res); - + // Check if execution was successful if (res.data?.data?.success) { return res.data.data.output; } - + // If not successful, throw error with message const errorMessage = res.data?.data?.error || res.data?.message || "Execution failed"; throw new Error(errorMessage); @@ -177,11 +187,11 @@ export const api = { withCredentials: true, headers: { "Content-Type": "application/json" }, }); - + if (res.data?.data) { return res.data.data; } - + throw new Error(res.data?.message || "Failed to fetch execution logs"); } } diff --git a/apps/web/app/lib/nodeConfigs/gmail.action.ts b/apps/web/app/lib/nodeConfigs/gmail.action.ts index c9e2fe1..40eabc5 100644 --- a/apps/web/app/lib/nodeConfigs/gmail.action.ts +++ b/apps/web/app/lib/nodeConfigs/gmail.action.ts @@ -6,8 +6,8 @@ export const gmailActionConfig: NodeConfig = { label: "Gmail", // āœ… Clean name icon: "šŸ“§", // āœ… Email icon description: "Send emails via Gmail", - credentials: "google_oauth", - + credentials: "gmail_oauth", + fields: [ { name: "credentialId", @@ -44,7 +44,7 @@ export const gmailActionConfig: NodeConfig = { description: "Body content of the email" } ], - + summary: "Send emails via Gmail", // āœ… Correct description helpUrl: "https://docs.example.com/gmail-action", diff --git a/apps/web/app/lib/nodeConfigs/googleSheet.action.ts b/apps/web/app/lib/nodeConfigs/googleSheet.action.ts index 4b5674f..154e24e 100644 --- a/apps/web/app/lib/nodeConfigs/googleSheet.action.ts +++ b/apps/web/app/lib/nodeConfigs/googleSheet.action.ts @@ -6,8 +6,8 @@ export const googleSheetActionConfig: NodeConfig = { label: "Google Sheet", icon: "šŸ“Š", description: "Read or write data to Google Sheets", - credentials: "google_oauth", // Requires Google OAuth - + credentials: "gsheet_oauth", // Requires Google OAuth + fields: [ { name: "credentialId", @@ -18,7 +18,7 @@ export const googleSheetActionConfig: NodeConfig = { description: "Choose which Google account to use" }, { - name: "spreadsheetId", + name: "spreadsheetId", type: "dropdown", label: "Spreadsheet", required: true, @@ -27,7 +27,7 @@ export const googleSheetActionConfig: NodeConfig = { }, { name: "sheetName", - type: "dropdown", + type: "dropdown", label: "Sheet", required: true, dependsOn: "spreadsheetId", @@ -39,22 +39,83 @@ export const googleSheetActionConfig: NodeConfig = { type: "dropdown", options: [ { label: "Read Rows", id: "read_rows" }, - { label: "Append Row", id: "append_row" }, - { label: "Update Row", id: "update_row" } + { label: "Append Rows", id: "append_rows" }, + { label: "Update Rows", id: "write_rows" }, + { label: "Clear Rows", id: "clear_rows" } ], required: true, defaultValue: "read_rows", description: "What operation to perform on the sheet" }, + { + name: "fetchEntireTable", + type: "checkbox", + label: "Fetch Entire Table", + defaultValue: true, + description: '"Automatically reads all rows from A1 to the last row of data', + required: true, + dependsOn: "operation", + showForOperation: ['read_rows'] + }, + { + name: 'clearEntireTable', + type: 'checkbox', + label: 'Clear Entire Table', + description: 'Automatically clears all rows from A1 to the last row of data', + required: true, + dependsOn: "operation", + showForOperation: ['clear_rows'], + defaultValue: true + }, { name: "range", - type: "text", - label: "Range", - value: "A1:Z100", - required: true + label: "Target Range / Cell", + type: "text", + placeholder: "e.g. A2, A1:C10, B5", + showForOperation: ["read_rows", "clear_rows", "write_rows"], + required: false, + dependsOn: "operation", + description: "Enter a custom range (e.g. A5:C20) if not fetching or clearing the entire table" + }, + { + name: "mappingMode", + label: "Data Mapping Mode", + type: "dropdown", + options: [ + { id: "visual", label: "Visual Column Mapper" }, + { id: "bulk", label: "Bulk JSON Array" } + ], + defaultValue: "visual", + showForOperation: ["append_rows", "write_rows"], + }, + { + name: "mappedColumns", + type: "column_mapper", + label: "Map Columns", + dependsOn: "mappingMode", + showForOperation: ["append_rows", "write_rows"], + description: "Map variables to specific Google Sheet columns" + }, + { + name: "bulkValues", + type: "textarea", + label: "Data to Write (JSON 2D Array)", + placeholder: '[["Value 1", "Value 2"], ["Value 3", "Value 4"]]', + dependsOn: "mappingMode", + showForOperation: ["append_rows", "write_rows"], + description: "Provide a JSON 2D array or an array variable from a previous node" + }, + { + name: "includeHeaderRow", + label: 'Clear Header Row', + type: 'checkbox', + defaultValue: false, + description: 'When unchecked, preserves Row 1 header titles and clears data starting from Row 2 (A2:Z)', + dependsOn: 'clearEntireTable', + showForOperation: ['clear_rows'] } ], - + summary: "Interact with Google Sheets spreadsheets", helpUrl: "https://docs.example.com/google-sheets-action", diff --git a/apps/web/app/lib/types/node.types.ts b/apps/web/app/lib/types/node.types.ts index f90b8a3..1b79e24 100644 --- a/apps/web/app/lib/types/node.types.ts +++ b/apps/web/app/lib/types/node.types.ts @@ -37,7 +37,7 @@ export interface NodeConfig { tags?: string[]; // Searchable tags // Any extra raw config data data?: Record; // Allow extra arbitrary config as needed - // NEW: What this node outputs (for next nodes to use) + // NEW: What this node outputs (for next nodes to use) outputSchema?: VariableDefinition[]; // NEW: Sample output for UI preview sampleOutput?: Record; @@ -46,14 +46,15 @@ export interface NodeConfig { export interface ConfigField { name: string; // Field's internal key, e.g., "sheetId" label: string; // Human-readable label, e.g., "Sheet ID" - type: "text" | "dropdown" | "textarea" | "number" | "checkbox" | "password"; + type: "text" | "dropdown" | "textarea" | "number" | "checkbox" | "password" | "column_mapper" | "bulk_payload"; required?: boolean; defaultValue?: string | number | boolean; // Initial value if not set placeholder?: string; - fetchOptions?: string, - value? : string, + fetchOptions?: string, + value?: string, options?: Array<{ label: string; id: string | number }>; // For dropdowns dependsOn?: string; // Name of another field this depends on + showForOperation?: Array; description?: string; // Help text for this field multiline?: boolean; // For textarea: allow specifying multiline min?: number; // For number fields: min value diff --git a/apps/web/app/workflows/[id]/components/ConfigModal.tsx b/apps/web/app/workflows/[id]/components/ConfigModal.tsx index d0bc3ee..e8ce223 100644 --- a/apps/web/app/workflows/[id]/components/ConfigModal.tsx +++ b/apps/web/app/workflows/[id]/components/ConfigModal.tsx @@ -1,13 +1,13 @@ "use client"; import { getNodeConfig } from "@/app/lib/nodeConfigs"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { HOOKS_URL } from "@repo/common/zod"; import { extractVariablesFromOutput, resolveConfigVariables, InterpolationContext } from "@repo/common/zod"; import { useAppSelector, useAppDispatch } from "@/app/hooks/redux"; import { toast } from "sonner"; import { useCredentials } from "@/app/hooks/useCredential"; import { api } from "@/app/lib/api"; -import { PreviousNodeOutput, VariableDefinition } from "@/app/lib/types/node.types"; +import { ConfigField, NodeConfig, PreviousNodeOutput, VariableDefinition } from "@/app/lib/types/node.types"; import { VariablePanel } from "@/app/components/ui/variable-panel"; import { setNodeOutput, @@ -39,13 +39,20 @@ export default function ConfigModal({ const [dynamicOptions, setDynamicOptions] = useState>({}); const [loading, setLoading] = useState(false); const [activeField, setActiveField] = useState(null); + const [activeSubField, setActiveSubField] = useState(null); const [testResult, setTestResult] = useState(null); + const [sheetHeaders, setSheetHeaders] = useState([]); + const [isLoadingHeaders, setIsLoadingHeaders] = useState(false); // Reset test result when switching to a different node useEffect(() => { setTestResult(null); }, [selectedNode?.id]); + // Add a ref to track which sheet's headers are currently loaded + const loadedSheetRef = useRef(""); + + const dispatch = useAppDispatch(); const userId = useAppSelector((state) => state.user.userId) as string; const reduxWorkflow = useAppSelector((state) => state.workflow.data); @@ -79,6 +86,7 @@ export default function ConfigModal({ // Build interpolation context from all previously tested nodes const buildTestContext = (): InterpolationContext => { const context: InterpolationContext = {}; + const nameCounts: Record = {}; console.log('[buildTestContext] All tested outputs:', allTestedOutputs); for (const [nodeId, testOutput] of Object.entries(allTestedOutputs)) { @@ -90,9 +98,23 @@ export default function ConfigModal({ if (testOutput.success && testOutput.data) { // Normalize node name: "Google Sheet" -> "google_sheet" - const normalizedName = testOutput.nodeName.toLowerCase().replace(/\s+/g, '_'); - console.log(`[buildTestContext] Normalized "${testOutput.nodeName}" -> "${normalizedName}"`); - context[normalizedName] = testOutput.data; + const baseName = testOutput.nodeName + .replace(/ Output$/i, '') + .replace(/ Node$/i, '') + .toLowerCase() + .replace(/\s+/g, '_'); + console.log(`[buildTestContext] Normalized "${testOutput.nodeName}" -> "${baseName}"`); + + context[nodeId] = testOutput.data; + + if (!nameCounts[baseName]) { + nameCounts[baseName] = 1; + context[baseName] = testOutput.data; // e.g. "google_sheet" + } else { + nameCounts[baseName]++; + const uniqueKey = `${baseName}_${nameCounts[baseName]}`; // e.g. "google_sheet_2" + context[uniqueKey] = testOutput.data; + } } } @@ -260,6 +282,17 @@ export default function ConfigModal({ const handleVariableInsert = (variableSyntax: string) => { if (!activeField) return; + if (activeField === "mappedColumns" && activeSubField) { + const currentMapped = config.mappedColumns || {}; + const currentVal = currentMapped[activeSubField] || ""; + const updatedMapped = { ...currentMapped, [activeSubField]: currentVal + variableSyntax }; + + const newConfig = { ...config, mappedColumns: updatedMapped }; + setConfig(newConfig); + dispatchConfig(newConfig); + return; + } + const currentValue = config[activeField] || ""; const newConfig = { ...config, [activeField]: currentValue + variableSyntax }; setConfig(newConfig) @@ -287,6 +320,22 @@ export default function ConfigModal({ setDynamicOptions((prev) => ({ ...prev, [depField.name]: options })); } } + + if ((fieldName === 'sheetName' || fieldName === 'spreadsheetId' || fieldName === "operation") && updatedConfig.sheetName) { + const { credentialId, spreadsheetId, sheetName, operation } = updatedConfig; + + if (credentialId && spreadsheetId && sheetName && ["append_rows", "write_rows"].includes(operation)) { + setIsLoadingHeaders(true); + try { + const headers = await api.google.getHeaders(credentialId, spreadsheetId, sheetName); + setSheetHeaders(headers || []); + } catch (e) { + console.error("Failed to load sheet headers", e); + } finally { + setIsLoadingHeaders(false); + } + } + } }; // console.log("This is the credential Data from config from backend" , config); // Fetch credentials with hook based on node config (google, etc) if appropriate @@ -320,6 +369,14 @@ export default function ConfigModal({ } } } + + if (loadedConfig.credentialId && loadedConfig.spreadsheetId && loadedConfig.sheetName && ["append_rows", "write_rows"].includes(loadedConfig.operation)) { + setIsLoadingHeaders(true) + api.google.getHeaders(loadedConfig.credentialId, loadedConfig.spreadsheetId, loadedConfig.sheetName) + .then((headers) => setSheetHeaders(headers || [])) + .catch((e) => console.error("Failed to load sheet headers", e)) + .finally(() => setIsLoadingHeaders(false)); + } } }, [selectedNode]); @@ -338,8 +395,36 @@ export default function ConfigModal({ // } // }; - const renderField = (field: any, nodeConfig: any) => { - const fieldValue = config[field.name] || ""; + const isFieldVisible = (field: ConfigField, formData: any) => { + const currentOps = formData.operation || "read_rows"; + + if (field.showForOperation && (!field.showForOperation.includes(currentOps))) + return false; + + if (field.name === 'range') { + if (currentOps === 'read_rows' && (formData.fetchEntireTable ?? true)) + return false + if (currentOps === 'clear_rows' && (formData.clearEntireTable ?? true)) + return false + if (currentOps === 'write_rows') + return true + } + + if (field.name === 'includeHeaderRow') { + if (currentOps === 'clear_rows' && !(formData.clearEntireTable ?? true)) + return false + } + + if (field.name === 'mappedColumns') + return (config.mappingMode ?? 'visual') === 'visual' + + if (field.name === 'bulkValues') + return config.mappingMode === 'bulk' + return true + } + + const renderField = (field: ConfigField, nodeConfig: any) => { + const fieldValue = config[field.name] ?? field.defaultValue ?? ""; if (field.type === "dropdown" && field.name === "credentialId") { // Use the values from useCredentials: credentials and authUrl @@ -364,7 +449,7 @@ export default function ConfigModal({ {credentials.map((cred: any) => ( ))} @@ -428,7 +513,7 @@ export default function ConfigModal({ className="w-full p-2.5 border border-[#1e293b] bg-[#0a0e17] text-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all outline-none text-sm" required={field.required} > - {console.log(options)} + {/* {console.log(options)} */} {options.map((opt: any) => (