From 6f36b4df8d8b8230add0f146ab692e22267b72bd Mon Sep 17 00:00:00 2001 From: BUDUMURU SRINIVAS SAI SARAN TEJA Date: Sun, 4 Jan 2026 22:01:29 +0530 Subject: [PATCH] feat: implement token refresh scheduler and enhance Google OAuth handling - Added TokenScheduler to manage periodic token refreshes. - Introduced tokenRefreshService for handling token refresh logic. - Enhanced Google OAuth callback with detailed logging and error handling. - Implemented update functionality for nodes and triggers in user routes. - Updated workflow slice to accommodate new ID structures. - Improved error handling and logging across various components. - Refactored Google Sheets service to conditionally include refresh tokens. - Added debug endpoints for credential management and token status. --- apps/http-backend/src/index.ts | 3 +- .../src/routes/google_callback.ts | 55 ++++- .../src/routes/userRoutes/userRoutes.ts | 78 +++++- .../src/scheduler/token-scheduler.ts | 68 ++++++ .../src/services/token-refresh.service.ts | 228 ++++++++++++++++++ .../app/components/nodes/CreateWorkFlow.tsx | 11 +- .../nodes/GoogleSheetFormClient.tsx | 88 +++++-- apps/web/app/components/nodes/actions.ts | 44 +++- apps/web/app/components/ui/app-sidebar.tsx | 57 ++++- apps/web/app/workflow/lib/config.ts | 68 +++++- apps/web/store/slices/workflowSlice.ts | 6 +- packages/common/src/index.ts | 12 +- .../nodes/src/common/google-oauth-service.ts | 11 +- .../google-sheets/google-sheets.service.ts | 15 +- packages/nodes/src/index.ts | 3 + 15 files changed, 700 insertions(+), 47 deletions(-) create mode 100644 apps/http-backend/src/scheduler/token-scheduler.ts create mode 100644 apps/http-backend/src/services/token-refresh.service.ts diff --git a/apps/http-backend/src/index.ts b/apps/http-backend/src/index.ts index f1920ae..7fc1d35 100644 --- a/apps/http-backend/src/index.ts +++ b/apps/http-backend/src/index.ts @@ -10,6 +10,7 @@ import { userRouter } from "./routes/userRoutes/userRoutes.js"; import cors from "cors" import { sheetRouter } from "./routes/nodes.routes.js"; import { googleAuth } from "./routes/google_callback.js"; +import { tokenScheduler } from "./scheduler/token-scheduler.js"; const app = express() @@ -40,7 +41,7 @@ app.use('/google', googleAuth) const PORT= 3002 async function startServer() { await NodeRegistry.registerAll() - + tokenScheduler.start(); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }) diff --git a/apps/http-backend/src/routes/google_callback.ts b/apps/http-backend/src/routes/google_callback.ts index f920cf2..ef17b21 100644 --- a/apps/http-backend/src/routes/google_callback.ts +++ b/apps/http-backend/src/routes/google_callback.ts @@ -23,10 +23,24 @@ googleAuth.get('/callback', async(req: Request, res: Response)=>{ 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 (state) is provided if (state && typeof state === 'string') { - + console.log(' Saving tokens for userId:', state); await Oauth.saveCredentials(state, tokens as OAuthTokens) + console.log(' βœ… Tokens saved to database'); } // Redirect to success page @@ -38,5 +52,42 @@ googleAuth.get('/callback', async(req: Request, res: Response)=>{ `http://localhost:3000/workflow?google=error&msg=${encodeURIComponent(err?.message ?? 'Token exchange failed')}`); } }) - +// 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(500).json({ error: err instanceof Error ? err.message : 'Unknown error' }); + } +}); diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index 498eaa1..f3164a0 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -10,6 +10,8 @@ import { TriggerSchema, WorkflowSchema, NodeSchema, + NodeUpdateSchema, + TriggerUpdateSchema, } from "@repo/common/zod"; import { GoogleSheetsNodeExecutor } from "@repo/nodes"; const router: Router = Router(); @@ -336,6 +338,7 @@ router.get('/empty/workflow', userMiddleware, async(req:AuthRequest, res: Respon }); } }) + router.get("/workflow/:workflowId", userMiddleware, async (req: AuthRequest, res: Response) => { @@ -437,7 +440,7 @@ router.post('/create/node', userMiddleware, async(req: AuthRequest, res: Respons }); } const data = req.body; - console.log(data," from http-backeden" ); + // console.log(data," from http-backeden" ); const dataSafe = NodeSchema.safeParse(data) if(!dataSafe.success) { @@ -469,6 +472,79 @@ router.post('/create/node', userMiddleware, async(req: AuthRequest, res: Respons } }) +// ------------------------- UPDATE NODES AND TRIGGES --------------------------- + +router.put('/update/node', userMiddleware, async(req: AuthRequest, res: Response)=>{ + try{ + if(!req.user){ + return res.status(statusCodes.BAD_REQUEST).json({ + message: "User is not logged in ", + }); + } + const data = req.body; + const dataSafe = NodeUpdateSchema.safeParse(data) + + if(!dataSafe.success) { + return res.status(statusCodes.BAD_REQUEST).json({ + message: "Invalid input" + }) + } + + const updateNode = await prismaClient.node.update({ + where: {id: dataSafe.data.NodeId}, + data:{ + config: dataSafe.data.Config + } + }) + + if(updateNode) + return res.status(statusCodes.CREATED).json({ + message: "Node updated", + data: updateNode + }) + + }catch(e){ + console.log("This is the error from Node updating", e); + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + message: "Internal server Error from Node Updation.", + }); + } +}) + +router.put('/update/trigger', userMiddleware, async(req: AuthRequest, res: Response)=>{ + try{ + if(!req.user){ + return res.status(statusCodes.BAD_REQUEST).json({ + message: "User is not logged in ", + }); + } + const data = req.body; + const dataSafe = TriggerUpdateSchema.safeParse(data) + + if(!dataSafe.success) + return res.status(statusCodes.BAD_REQUEST).json({ + message: "Invalid input" + }) + + const updatedTrigger = await prismaClient.trigger.update({ + where:{id: dataSafe.data.TriggerId}, + data:{ + config: dataSafe.data.Config + } + }) + + if(updatedTrigger) + return res.status(statusCodes.CREATED).json({ + message: "Trigger updated", + data: updatedTrigger + }) + }catch(e){ + console.log("This is the error from Trigger updating", e); + return res.status(statusCodes.INTERNAL_SERVER_ERROR).json({ + message: "Internal server Error from Trigger Updation", + }); + } +}) // ----------------------- GET WORKFLOW DATA(NODES, TRIGGER)--------------------- diff --git a/apps/http-backend/src/scheduler/token-scheduler.ts b/apps/http-backend/src/scheduler/token-scheduler.ts new file mode 100644 index 0000000..60bb631 --- /dev/null +++ b/apps/http-backend/src/scheduler/token-scheduler.ts @@ -0,0 +1,68 @@ +import { tokenRefreshService } from "../services/token-refresh.service.js"; + +class TokenScheduler { + private intervalId: NodeJS.Timeout | null = null; + private intervalMinutes: number; + + constructor(intervalMinutes: number = 60) { + this.intervalMinutes = intervalMinutes; + } + + /** + * Start the token refresh scheduler + */ + start(): void { + console.log(`\nπŸš€ Token Refresh Scheduler started`); + console.log(` Interval: Every ${this.intervalMinutes} minutes`); + console.log(` Next run: Immediately + every ${this.intervalMinutes} min\n`); + + // Run immediately on start + this.runRefreshJob(); + + // Then run at specified interval + const intervalMs = this.intervalMinutes * 60 * 1000; + this.intervalId = setInterval(() => { + this.runRefreshJob(); + }, intervalMs); + } + + /** + * Stop the scheduler + */ + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + console.log('πŸ›‘ Token Refresh Scheduler stopped'); + } + } + + /** + * Run the refresh job + */ + private async runRefreshJob(): Promise { + try { + const timestamp = new Date().toISOString(); + console.log(`\n⏱️ [${timestamp}] Running scheduled token refresh...`); + + await tokenRefreshService.refreshAllExpiringTokens(); + + } catch (error) { + console.error('❌ Scheduled token refresh failed:', error); + } + } + + /** + * Manually trigger a refresh (useful for testing or on-demand refresh) + */ + async triggerManualRefresh(): Promise { + console.log('\nπŸ”§ Manual token refresh triggered...'); + await this.runRefreshJob(); + } +} + +// Default scheduler instance - runs every 30 minutes +export const tokenScheduler = new TokenScheduler(60); + +// Export class for custom configurations +export { TokenScheduler }; diff --git a/apps/http-backend/src/services/token-refresh.service.ts b/apps/http-backend/src/services/token-refresh.service.ts new file mode 100644 index 0000000..9e3351c --- /dev/null +++ b/apps/http-backend/src/services/token-refresh.service.ts @@ -0,0 +1,228 @@ +import { prismaClient } from "@repo/db/client"; +import { GoogleSheetsService, GoogleOAuthService } from "@repo/nodes"; + +interface OAuthTokens { + access_token: string; + refresh_token: string; + token_type: string; + expiry_date: number; + scope?: string; +} + +interface RefreshResult { + credentialId: string; + success: boolean; + error?: string; +} + +class TokenRefreshService { + private oauthService: GoogleOAuthService; + + constructor() { + this.oauthService = new GoogleOAuthService(); + } + + /** + * Check if token is expired or expiring soon (within buffer time) + */ + private isTokenExpiring(expiryDate: number, bufferMinutes: number = 10): boolean { + const bufferMs = bufferMinutes * 60 * 1000; + return expiryDate < (Date.now() + bufferMs); + } + + /** + * Refresh a single credential's access token using existing GoogleSheetsService + */ + private async refreshToken(credentialId: string, tokens: OAuthTokens): Promise { + try { + // Use your existing GoogleSheetsService to refresh the token + const sheetService = new GoogleSheetsService({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: tokens.token_type, + expiry_date: tokens.expiry_date + }); + + // Use your existing refreshAccessToken method + const newTokens = await sheetService.refreshAccessToken(); + + // Use your existing updateCredentials method from GoogleOAuthService + await this.oauthService.updateCredentials(credentialId, { + access_token: newTokens.access_token, + refresh_token: newTokens.refresh_token || tokens.refresh_token, + token_type: newTokens.token_type, + expiry_date: newTokens.expiry_date + }); + + console.log(`βœ… Token refreshed for credential: ${credentialId}`); + return { credentialId, success: true }; + + } 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({ + where: { + config: { + path: ['credId'], + equals: credentialId + } + } + }); + + for (const trigger of triggers) { + const config = trigger.config as any; + delete config.credId; + await prismaClient.trigger.update({ + where: { id: trigger.id }, + data: { config } + }); + console.log(` Cleared credId from Trigger ${trigger.id}`); + } + + // 2. Clear credId from any Nodes that use this credential + const nodes = await prismaClient.node.findMany({ + where: { + config: { + path: ['credId'], + equals: credentialId + } + } + }); + + for (const node of nodes) { + const config = node.config as any; + delete config.credId; + await prismaClient.node.update({ + where: { id: node.id }, + data: { config } + }); + console.log(` Cleared credId from Node ${node.id}`); + } + + // 3. Delete the credential + 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.' + }; + } + + console.error(`❌ Failed to refresh token for credential ${credentialId}: ${errorMessage}`); + return { credentialId, success: false, error: errorMessage }; + } + } + + /** + * Check and refresh all Google OAuth credentials that are expiring + */ + 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' + } + }); + + console.log(`πŸ“‹ Found ${credentials.length} Google OAuth credentials`); + + let refreshed = 0; + let failed = 0; + let deleted = 0; + + for (const credential of credentials) { + const tokens = credential.config as unknown as OAuthTokens; + + // Skip if no tokens or no expiry_date + if (!tokens || !tokens.expiry_date) { + console.log(`⏭️ Skipping credential ${credential.id}: No expiry_date found`); + continue; + } + + // Check if token is expiring soon + 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')) { + deleted++; + } else { + failed++; + } + } else { + const expiresIn = Math.round((tokens.expiry_date - Date.now()) / 1000 / 60); + console.log(`βœ“ Credential ${credential.id} still valid (expires in ${expiresIn} minutes)`); + } + } + + console.log(`\nπŸ“Š Token refresh job completed:`); + console.log(` Total credentials: ${credentials.length}`); + console.log(` Refreshed: ${refreshed}`); + console.log(` Deleted (invalid): ${deleted}`); + console.log(` Failed: ${failed}`); + console.log(` Skipped (still valid): ${credentials.length - refreshed - failed - deleted}\n`); + + return { total: credentials.length, refreshed, failed, deleted }; + + } catch (error) { + console.error('❌ Token refresh job failed:', error instanceof Error ? error.message : error); + throw error; + } + } + + /** + * Check and refresh a single credential by ID + */ + async refreshSingleCredential(credentialId: string): Promise { + try { + const credential = await prismaClient.credential.findUnique({ + where: { id: credentialId } + }); + + if (!credential) { + return { credentialId, success: false, error: 'Credential not found' }; + } + + const tokens = credential.config as unknown as OAuthTokens; + + if (!tokens || !tokens.refresh_token) { + return { credentialId, success: false, error: 'No refresh token available' }; + } + + return await this.refreshToken(credentialId, tokens); + + } catch (error) { + return { + credentialId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } +} + +export const tokenRefreshService = new TokenRefreshService(); +export { TokenRefreshService }; diff --git a/apps/web/app/components/nodes/CreateWorkFlow.tsx b/apps/web/app/components/nodes/CreateWorkFlow.tsx index 619ac7f..7eb41bf 100644 --- a/apps/web/app/components/nodes/CreateWorkFlow.tsx +++ b/apps/web/app/components/nodes/CreateWorkFlow.tsx @@ -47,7 +47,8 @@ export const CreateWorkFlow = () => { const workflowId = useAppSelector(s=>s.workflow.workflow_id) const existingTrigger = useAppSelector(s=>s.workflow.trigger) const existingNodes = useAppSelector(s=>s.workflow.nodes) - // console.log(`workflow from redux, TRigger: ${existingTrigger}, Nodes: ${existingNodes}`) + console.log(`workflow from redux, TRigger: ${existingTrigger?.AvailableTriggerID}, Nodes: ${existingNodes}`) + console.log('redux workflow from createWorkflow: ',workflowId) const [nodes, setNodes] = useState([ { @@ -140,6 +141,7 @@ export const CreateWorkFlow = () => { if(!workflowId) return const workflow = await getworkflowData(workflowId) if(workflow.success){ + console.log("workflow data called") dispatch(workflowActions.setWorkflowStatus(false)) dispatch(workflowActions.setWorkflowNodes(workflow.data.nodes)) dispatch(workflowActions.setWorkflowTrigger(workflow.data.Trigger)) @@ -165,8 +167,9 @@ export const CreateWorkFlow = () => { const newEdges: EdgeType[] = []; const type = existingTrigger?.name.split(" - ")[0] if(existingTrigger){ + console.log("trigger id from redux: ", existingTrigger) newNodes.push({ - id: `trigger-${existingTrigger.dbId}`, + id: `trigger~${existingTrigger.id}`, type: 'trigger' as const, position: { x: START_X, y: Y}, data:{ @@ -180,7 +183,7 @@ export const CreateWorkFlow = () => { } existingNodes.forEach((node, index)=>{ - const nodeId = `action-${node.dbId}-${index}`; + const nodeId = `action~${node.id}~${index}`; const type = node.name.split(" - ")[0] newNodes.push({ id: nodeId, @@ -217,7 +220,7 @@ export const CreateWorkFlow = () => { } loadWorkflow() - },[existingNodes, existingTrigger]) + },[existingNodes, existingTrigger, dispatch, workflowId]) const handleSelectAction = (action: { diff --git a/apps/web/app/components/nodes/GoogleSheetFormClient.tsx b/apps/web/app/components/nodes/GoogleSheetFormClient.tsx index df738c9..4e29a97 100644 --- a/apps/web/app/components/nodes/GoogleSheetFormClient.tsx +++ b/apps/web/app/components/nodes/GoogleSheetFormClient.tsx @@ -7,15 +7,17 @@ import { Label } from '@workspace/ui/components/label'; import React, { useEffect, useState, useTransition } from 'react'; import Link from 'next/link'; import { toast } from 'sonner'; -import { handleSaveConfig } from './actions'; +import { handleSaveConfig, handleUpdateConfig } from './actions'; import { useCredentials } from '@/app/hooks/useCredential'; import { BACKEND_URL } from '@repo/common/zod'; import { useAppSelector } from '@/app/hooks/redux'; +import { useDispatch } from 'react-redux'; +import { workflowActions } from '@/store/slices/workflowSlice'; interface GoogleSheetFormClientProps { type: string; nodeType: string; - + avlNode?: string ; position: number; initialData?: { range?: string; @@ -26,7 +28,7 @@ interface GoogleSheetFormClientProps { }; } -export function GoogleSheetFormClient({ type, nodeType, position, initialData }: GoogleSheetFormClientProps) { +export function GoogleSheetFormClient({ type, nodeType, avlNode, position, initialData }: GoogleSheetFormClientProps) { const [selectedCredential, setSelectedCredential] = useState(initialData?.credentialId || ''); const [documents, setDocuments] = useState>([]); const [selectedDocument, setSelectedDocument] = useState(initialData?.spreadSheetId || ''); @@ -38,27 +40,29 @@ export function GoogleSheetFormClient({ type, nodeType, position, initialData }: const [isPending, startTransition] = useTransition(); const [result, setResult] = useState(null); const [credId, setCredId] = useState(initialData?.credentialId || ''); + + const dispatch = useDispatch() // const [authUrl, setAuthUrl] = useState() - console.log('initial data: ', initialData) - console.log('initial document: ', selectedDocument) - console.log('initial sheet: ', selectedSheet) - console.log('initial range: ', range) - console.log("initial operation: ",operation) + // console.log('initial data: ', initialData) + // console.log('initial document: ', selectedDocument) + // console.log('initial sheet: ', selectedSheet) + // console.log('initial range: ', range) + // console.log("initial operation: ",operation) const userId = useAppSelector(s=>s.user.userId) || "" const workflowId = useAppSelector(s=>s.workflow.workflow_id) || '' - console.log(userId, 'id from client') + // console.log(userId, 'id from client') const credType = type const nodeTypeParsed = nodeType.split("~")[0] || "" - console.log('checking nodeType: ', nodeTypeParsed); + // console.log('checking nodeType: ', nodeTypeParsed); const nodeId = nodeType.split("~")[1] || "" - console.log('checking node id: ',nodeId) + // console.log('checking node id: ',nodeId) const {cred: response, authUrl} = useCredentials(credType) - console.log('response from form client', typeof(response)) + // console.log('response from form client', typeof(response)) - console.log(response," response from client after hook") - console.log(authUrl," authurl") + // console.log(response," response from client after hook") + // console.log(authUrl," authurl") // Fetch documents when there's initial credentialId useEffect(() => { @@ -135,8 +139,9 @@ export function GoogleSheetFormClient({ type, nodeType, position, initialData }: setSelectedCredential(credentialId); setDocuments([]); setSheets([]); - setSelectedDocument(''); - setSelectedSheet(''); + // Don't clear document/sheet if we have initial values - we'll try to restore them + if (!initialData?.spreadSheetId) setSelectedDocument(''); + if (!initialData?.sheetName) setSelectedSheet(''); if (!credentialId || credentialId === 'create-new') return; setCredId(credentialId) @@ -149,8 +154,37 @@ export function GoogleSheetFormClient({ type, nodeType, position, initialData }: }); const data = await response.json(); - if (data.files.length >0) { + if (data.files.length > 0) { setDocuments(data.files || []); + + // Restore previously selected document if it exists in the list + if (initialData?.spreadSheetId) { + const docExists = data.files.some((doc: any) => doc.id === initialData.spreadSheetId); + if (docExists) { + setSelectedDocument(initialData.spreadSheetId); + // Also fetch sheets for this document to restore sheet selection + try { + const sheetsRes = await fetch(`${BACKEND_URL}/node/getSheets/${credentialId}/${initialData.spreadSheetId}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: "include", + }); + const sheetsData = await sheetsRes.json(); + if (sheetsData.files?.data?.length > 0) { + setSheets(sheetsData.files.data); + // Restore previously selected sheet if it exists + if (initialData?.sheetName) { + const sheetExists = sheetsData.files.data.some((s: any) => s.name === initialData.sheetName); + if (sheetExists) { + setSelectedSheet(initialData.sheetName); + } + } + } + } catch (err) { + console.error('Failed to restore sheets:', err); + } + } + } } } catch (error) { console.error('Failed to fetch documents:', error); @@ -203,7 +237,23 @@ export function GoogleSheetFormClient({ type, nodeType, position, initialData }: startTransition(async () => { console.log('Sending config:', config); - const res = await handleSaveConfig(config); + let res: any + if(initialData){ + const updateConfig = { + type: nodeTypeParsed, + credentialId: selectedCredential, + spreadsheetId: selectedDocument, + sheetName: selectedSheet, + operation: operation, + range: range, + id: nodeId + } + res = await handleUpdateConfig(updateConfig) + } + else { + res = await handleSaveConfig(config); + dispatch(workflowActions.addWorkflowNode(res.data.data)) + } console.log('Full response:', res); // console.log('Success:', res.success); // console.log('Output:', res.output); @@ -357,7 +407,7 @@ export function GoogleSheetFormClient({ type, nodeType, position, initialData }: disabled={!selectedCredential || !selectedDocument || !selectedSheet || !range || isPending} onClick={handleSaveClick} > - {isPending ? 'Saving…' : 'Save Configuration'} + {isPending ? (initialData ? 'Updating...' : 'Saving…') : (initialData ? 'Update Configuration' : 'Save Configuration')} {result?.success === false &&

{result.error}

} {result?.authUrl && ( diff --git a/apps/web/app/components/nodes/actions.ts b/apps/web/app/components/nodes/actions.ts index 8ecb06a..9bd6cb4 100644 --- a/apps/web/app/components/nodes/actions.ts +++ b/apps/web/app/components/nodes/actions.ts @@ -1,5 +1,5 @@ -import { createNode, createTrigger } from '@/app/workflow/lib/config'; +import { createNode, createTrigger, updateNode, updateTrigger } from '@/app/workflow/lib/config'; interface SaveConfigFormData { // userId: string; @@ -16,6 +16,16 @@ interface SaveConfigFormData { } +interface updateConfigData{ + type: string + credentialId: string; + spreadsheetId: string; + sheetName: string; + operation: string; + range: string; + id: string +} + export async function handleSaveConfig(formData: SaveConfigFormData) { // const executor = new GoogleSheetsNodeExecutor(); const context = { @@ -62,3 +72,35 @@ export async function handleSaveConfig(formData: SaveConfigFormData) { // requiresAuth: result.requiresAuth || false, // }; } + +export async function handleUpdateConfig(formData: updateConfigData){ + const data = { + id: formData.id, + config: { + credId: formData.credentialId, + spreadsheetId: formData.spreadsheetId, + sheetName: formData.sheetName, + operation: formData.operation, + range: formData.range + } + } + if(formData.type === 'trigger') + { + const trigger = await updateTrigger(data) + console.log('triggger updated using config backend: ',trigger) + + return{ + success: trigger.success, + data: trigger.data + } + } + else{ + const node = await updateNode(data) + console.log('NOde updated using config backend: ',node); + return { + success:node.success, + data: node.data + } + } + +} \ No newline at end of file diff --git a/apps/web/app/components/ui/app-sidebar.tsx b/apps/web/app/components/ui/app-sidebar.tsx index 8260a37..9f1043f 100644 --- a/apps/web/app/components/ui/app-sidebar.tsx +++ b/apps/web/app/components/ui/app-sidebar.tsx @@ -26,10 +26,10 @@ import { } from '@workspace/ui/components/dropdown-menu' import { ChevronDown, ChevronUp, Key, LogOut, LucideLayoutDashboard, PlusCircle, User2, WorkflowIcon } from 'lucide-react' import { useEffect, useState } from 'react' -import { createWorkflow, getAllCredentials, getAllWorkflows, getEmptyWorkflow } from '@/app/workflow/lib/config' +import { createWorkflow, getAllCredentials, getAllWorkflows, getEmptyWorkflow, getworkflowData } from '@/app/workflow/lib/config' import { useAppDispatch, useAppSelector } from '@/app/hooks/redux' import { userAction } from '@/store/slices/userSlice' -import { workflowActions } from '@/store/slices/workflowSlice' +import { workflowActions, workflowReducer } from '@/store/slices/workflowSlice' import { toast } from 'sonner' import { signOut } from 'next-auth/react' import { useRouter } from 'next/navigation' @@ -38,25 +38,61 @@ export function AppSidebar() { const user = useAppSelector((s)=> s.user) const flow = useAppSelector(s=>s.workflow) // workflow + console.log('redux workflow from sidebar: ',flow) const dispatch = useAppDispatch() const router = useRouter() const [selectedWorkflow, setSelectedWorkflow] = useState(flow.workflow_id) const [workflow, setWorkflow] = useState>() const [creds, setCreds] = useState>() - useEffect(()=>{ - async function getWorkflows(){ + + async function getWorkflows(){ const flows = await getAllWorkflows(); if(flows) setWorkflow(flows) } + useEffect(()=>{ async function getCreds(){ const credentials = await getAllCredentials(); if(credentials) setCreds(credentials) } + async function getWorkflowData(){ + console.log("workflow data called") + if(!flow.workflow_id) return + const workflow = await getworkflowData(flow.workflow_id) + if(workflow.success){ + console.log("workflow data fetchedsuceesully: ", workflow.data) + dispatch(workflowActions.setWorkflowStatus(false)) + dispatch(workflowActions.setWorkflowNodes(workflow.data.nodes)) + dispatch(workflowActions.setWorkflowTrigger(workflow.data.Trigger)) + // console.log(`workfklow from redux: ${workflow.data}`) + } + } + // async function setEmptyFlow(){ + // const workflow = await getEmptyWorkflow() + // if(workflow){ + // const {id, isEmpty} = workflow + // dispatch(workflowActions.setWorkflowId(id)) + // dispatch(workflowActions.setWorkflowStatus(isEmpty)) + // } + // else{ + // const newWorkflow = await createWorkflow() + // dispatch(workflowActions.clearWorkflow()) + // setSelectedWorkflow(null) + // dispatch(workflowActions.setWorkflowId(newWorkflow.id)) + // dispatch(workflowActions.setWorkflowStatus(newWorkflow.isEmpty)) + // toast.success("Workflow created") + // getWorkflows() + // } + // } + if(!creds) getCreds() - if(!workflow) getWorkflows() - },[selectedWorkflow]) + if(!workflow) { + getWorkflows() + createNewWorkflow() + } + getWorkflowData() + },[flow.workflow_id, dispatch]) const workflowHandler = (wId: string)=>{ dispatch(workflowActions.setWorkflowId(wId)) @@ -89,6 +125,7 @@ export function AppSidebar() { dispatch(workflowActions.setWorkflowId(newWorkflow.id)) dispatch(workflowActions.setWorkflowStatus(newWorkflow.isEmpty)) toast.success("Workflow created") + getWorkflows() } } // console.log(`workflow form ${workflow}`) @@ -99,12 +136,12 @@ export function AppSidebar() { - + - Create Workflow + Create Workflow @@ -113,7 +150,7 @@ export function AppSidebar() { - + Workflows @@ -144,7 +181,7 @@ export function AppSidebar() { - + Credentials diff --git a/apps/web/app/workflow/lib/config.ts b/apps/web/app/workflow/lib/config.ts index 26ff178..6cca46f 100644 --- a/apps/web/app/workflow/lib/config.ts +++ b/apps/web/app/workflow/lib/config.ts @@ -79,7 +79,7 @@ export const createWorkflow = async()=>{ try{ const response = await axios.post(`${BACKEND_URL}/user/create/workflow`, { - Name:`workflow-${date.getTime()}`, + Name:`workflow-${date.toString()}`, // UserId: userId, Config: {} },{ @@ -114,7 +114,7 @@ interface Triggercontext{ }; export const createTrigger = async(context: Triggercontext)=>{ try{ - console.log('Trigger context: ', context) + // console.log('Trigger context: ', context) const response = await axios.post(`${BACKEND_URL}/user/create/trigger`, { Name: context.name, @@ -143,6 +143,70 @@ export const createTrigger = async(context: Triggercontext)=>{ } } +interface updateContext{ + config: { + credId: string, + operation: string, + spreadsheetId: string, + range: string, + sheetName: string, + }, + id: string +} + +export const updateTrigger = async(context: updateContext)=>{ + try{ + const res = await axios.put(`${BACKEND_URL}/user/update/trigger`, + { + TriggerId: context.id, + Config: context.config + },{ + headers: {"Content-Type": "application/json"}, + withCredentials: true + } + ) + const trigger = (res.data) + console.log('trigger created: ', trigger); + return { + success: true, + data: trigger + } + }catch(e){ + console.error("Error in updating Trigger:", e); + return { + success: false, + data: e instanceof Error ? e.message : `unknown error ${e}` + } + } +} + +export const updateNode = async(context: updateContext)=>{ + try{ + const res = await axios.put(`${BACKEND_URL}/user/update/node`, + { + NodeId: context.id, + Config: context.config + }, + { + withCredentials: true, + headers: {"Content-Type": "application/json"} + } + ) + const node = (res.data) + console.log('node updated: ', node); + return { + success: true, + data: node + } + }catch(e){ + console.error("Error in updating Node:", e); + return { + success: false, + data: e instanceof Error ? e.message : `unknown error ${e}` + } + } +} + interface Nodecontext{ // userId: formData.userId, name: string, diff --git a/apps/web/store/slices/workflowSlice.ts b/apps/web/store/slices/workflowSlice.ts index 2cd9dfa..84ec692 100644 --- a/apps/web/store/slices/workflowSlice.ts +++ b/apps/web/store/slices/workflowSlice.ts @@ -1,18 +1,20 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; interface Trigger { - dbId: string; + id: string; name: string; type: string; config: any; + AvailableTriggerID: string } interface NodeItem { - dbId: string; + id: string; name: string; type: string; config: any; position: number; + AvailableNodeID: string } type Nodes = NodeItem[]; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 55f349c..5a7b34f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,5 @@ import z from "zod"; +import { string } from "zod/v4"; export const BACKEND_URL="http://localhost:3002"; @@ -33,6 +34,15 @@ export const NodeSchema = z.object({ WorkflowId: z.string(), }); +export const NodeUpdateSchema = z.object({ + NodeId: z.string(), + Config: z.any() +}) + +export const TriggerUpdateSchema = z.object({ + TriggerId: z.string(), + Config: z.any() +}) export const WorkflowSchema = z.object({ Name: z.string(), @@ -45,7 +55,7 @@ export enum statusCodes { CREATED = 201, ACCEPTED = 202, NO_CONTENT = 204, - + FOUND = 302, BAD_REQUEST = 400, UNAUTHORIZED = 401, FORBIDDEN = 403, diff --git a/packages/nodes/src/common/google-oauth-service.ts b/packages/nodes/src/common/google-oauth-service.ts index 3b7813a..4f62d1f 100644 --- a/packages/nodes/src/common/google-oauth-service.ts +++ b/packages/nodes/src/common/google-oauth-service.ts @@ -126,9 +126,17 @@ class GoogleOAuthService{ if(!existing) throw new Error(`No Credential found`); + // Filter out empty/falsy values to prevent overwriting valid tokens + const filteredTokens: Partial = {}; + if (tokens.access_token) filteredTokens.access_token = tokens.access_token; + if (tokens.refresh_token) filteredTokens.refresh_token = tokens.refresh_token; + if (tokens.token_type) filteredTokens.token_type = tokens.token_type; + if (tokens.expiry_date) filteredTokens.expiry_date = tokens.expiry_date; + if (tokens.scope) filteredTokens.scope = tokens.scope; + const updatedConfig = { ...(existing.config as object), - ...(tokens) + ...filteredTokens } await this.prisma.credential.update({ @@ -140,6 +148,7 @@ class GoogleOAuthService{ } }); + console.log(`βœ… Credentials updated for ${credentialId}`); } catch(e){ throw new Error(`Failed to update Credentials: ${e instanceof Error ? e.message : "unknown error"}`) diff --git a/packages/nodes/src/google-sheets/google-sheets.service.ts b/packages/nodes/src/google-sheets/google-sheets.service.ts index 761d6c9..6ad433e 100644 --- a/packages/nodes/src/google-sheets/google-sheets.service.ts +++ b/packages/nodes/src/google-sheets/google-sheets.service.ts @@ -113,15 +113,24 @@ class GoogleSheetsService{ try{ const {credentials} = await this.auth.refreshAccessToken(); - return { + // IMPORTANT: Only include refresh_token if Google returns a new one + // Google doesn't always return a new refresh_token on every refresh + const result: GoogleSheetsCredentials = { access_token: credentials.access_token || '', - refresh_token: credentials.refresh_token || '', + refresh_token: '', // Will be set below if present token_type: credentials.token_type || '', expiry_date: credentials.expiry_date || 0 + }; + + // Only include refresh_token if Google actually returned one + if (credentials.refresh_token) { + result.refresh_token = credentials.refresh_token; } + + return result; } catch (error){ - throw new Error(`Falied to refresh token: ${error}`) + throw new Error(`Failed to refresh token: ${error}`) } } } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 84672c1..5b229fe 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,10 +1,13 @@ // Central export for all major modules // export { default as NodeRegistry } from './registry/node-registry.js'; + + // Fixed lint error: Use correct export name 'GoogleSheetNode' export { GoogleSheetNode } from './google-sheets/google-sheets.node.js'; export type { OAuthTokens } from './common/google-oauth-service.js' export { default as GoogleSheetsNodeExecutor } from './google-sheets/google-sheets.executor.js'; export { GoogleOAuthService } from './common/google-oauth-service.js'; export { default as NodeRegistry } from './registry/node-registry.js'; +export { GoogleSheetsService } from './google-sheets/google-sheets.service.js'; console.log("Hello World From node / index.ts");