Browse Source

fix: ShopRenter token refresh endpoint and error handling #79

- Fixed token refresh API endpoint: use oauth.app.shoprenter.net instead of api2.myshoprenter.hu
- Added fallback to client_credentials flow if refresh fails and credentials are stored
- Fixed error handling in shoprenter-sync to properly propagate errors to UI
- Added error tracking for products, orders, and customers sync
- Return HTTP 500 on complete failure, 207 on partial success, 200 on success
- Store sync_error in database for visibility
Claude 5 months ago
parent
commit
a26de457b1

+ 53 - 20
supabase/functions/_shared/shoprenter-client.ts

@@ -339,22 +339,54 @@ export async function getValidAccessToken(storeId: string): Promise<string> {
       // Token needs refresh
       console.log('[ShopRenter] Token expired or expiring soon, refreshing...')
       if (store.api_secret) {
-        const newTokenData = await refreshAccessToken(store.store_name, store.api_secret)
-
-        const newExpiresAt = new Date(Date.now() + (newTokenData.expires_in * 1000)).toISOString()
-
-        // Update store with new tokens (use token_expires_at column, not alt_data)
-        await supabase
-          .from('stores')
-          .update({
-            api_key: newTokenData.access_token,
-            api_secret: newTokenData.refresh_token || store.api_secret,
-            token_expires_at: newExpiresAt
-          })
-          .eq('id', storeId)
-
-        console.log('[ShopRenter] Token refreshed successfully, expires at:', newExpiresAt)
-        return newTokenData.access_token
+        try {
+          const newTokenData = await refreshAccessToken(store.store_name, store.api_secret)
+
+          const newExpiresAt = new Date(Date.now() + (newTokenData.expires_in * 1000)).toISOString()
+
+          // Update store with new tokens (use token_expires_at column, not alt_data)
+          await supabase
+            .from('stores')
+            .update({
+              api_key: newTokenData.access_token,
+              api_secret: newTokenData.refresh_token || store.api_secret,
+              token_expires_at: newExpiresAt
+            })
+            .eq('id', storeId)
+
+          console.log('[ShopRenter] Token refreshed successfully, expires at:', newExpiresAt)
+          return newTokenData.access_token
+        } catch (refreshError) {
+          console.error('[ShopRenter] Token refresh failed:', refreshError)
+
+          // If we have client credentials stored in alt_data, try using client_credentials flow as fallback
+          if (store.alt_data?.client_id && store.alt_data?.client_secret) {
+            console.log('[ShopRenter] Attempting fallback to client_credentials flow with stored credentials')
+            const tokenData = await getTokenWithClientCredentials(
+              store.store_name,
+              store.alt_data.client_id,
+              store.alt_data.client_secret
+            )
+
+            const expiresAt = new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString()
+
+            // Update store with the new access token
+            await supabase
+              .from('stores')
+              .update({
+                api_key: tokenData.access_token,
+                api_secret: tokenData.refresh_token || store.api_secret,
+                token_expires_at: expiresAt
+              })
+              .eq('id', storeId)
+
+            console.log('[ShopRenter] Access token obtained via client_credentials fallback, expires at:', expiresAt)
+            return tokenData.access_token
+          }
+
+          // No fallback available, re-throw the error
+          throw refreshError
+        }
       }
     }
 
@@ -412,8 +444,9 @@ async function refreshAccessToken(shopname: string, refreshToken: string) {
     throw new Error('ShopRenter credentials not configured')
   }
 
-  const hostname = `${shopname}.api2.myshoprenter.hu`
-  const path = '/api/oauth/token'
+  // Use the same endpoint as token request: oauth.app.shoprenter.net
+  const hostname = 'oauth.app.shoprenter.net'
+  const path = `/${shopname}/app/token`
 
   const requestBody = JSON.stringify({
     grant_type: 'refresh_token',
@@ -434,7 +467,7 @@ async function refreshAccessToken(shopname: string, refreshToken: string) {
 
     if (response.status !== 200) {
       console.error('[ShopRenter] Token refresh error:', response.body)
-      throw new Error('Failed to refresh token')
+      throw new Error(`Failed to refresh token: ${response.status} ${response.body}`)
     }
 
     const data = JSON.parse(response.body)
@@ -442,7 +475,7 @@ async function refreshAccessToken(shopname: string, refreshToken: string) {
     return data
   } catch (error) {
     console.error('[ShopRenter] Token refresh error:', error)
-    throw new Error('Failed to refresh token')
+    throw error
   }
 }
 

+ 53 - 3
supabase/functions/shoprenter-sync/index.ts

@@ -367,6 +367,7 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
 
     // Sync Products
     const allProducts: any[] = []
+    let productSyncError: Error | null = null
     try {
       if (!canSyncProducts) {
         console.log('[ShopRenter] Product sync disabled by store permissions')
@@ -427,10 +428,12 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
       }
     } catch (error) {
       console.error('[ShopRenter] Product sync error:', error)
+      productSyncError = error as Error
       syncStats.products.errors++
     }
 
     // Sync Orders
+    let orderSyncError: Error | null = null
     try {
       console.log('[ShopRenter] Syncing orders...')
       let page = 0  // ShopRenter API uses zero-based pagination
@@ -494,10 +497,12 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
       console.log(`[ShopRenter] Orders synced: ${syncStats.orders.synced}`)
     } catch (error) {
       console.error('[ShopRenter] Order sync error:', error)
+      orderSyncError = error as Error
       syncStats.orders.errors++
     }
 
     // Sync Customers
+    let customerSyncError: Error | null = null
     try {
       console.log('[ShopRenter] Syncing customers...')
       let page = 0  // ShopRenter API uses zero-based pagination
@@ -557,12 +562,28 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
       console.log(`[ShopRenter] Customers synced: ${syncStats.customers.synced}`)
     } catch (error) {
       console.error('[ShopRenter] Customer sync error:', error)
+      customerSyncError = error as Error
       syncStats.customers.errors++
     }
 
     // Update BOTH stores table and store_sync_config to maintain consistency
     const syncCompletedAt = new Date().toISOString()
 
+    // Check if any critical errors occurred
+    const hasErrors = productSyncError || orderSyncError || customerSyncError
+    const totalErrors = syncStats.products.errors + syncStats.orders.errors + syncStats.customers.errors
+    const totalSynced = syncStats.products.synced + syncStats.orders.synced + syncStats.customers.synced
+
+    // Build error message if any errors occurred
+    let errorMessage: string | null = null
+    if (hasErrors) {
+      const errorParts: string[] = []
+      if (productSyncError) errorParts.push(`Products: ${productSyncError.message}`)
+      if (orderSyncError) errorParts.push(`Orders: ${orderSyncError.message}`)
+      if (customerSyncError) errorParts.push(`Customers: ${customerSyncError.message}`)
+      errorMessage = errorParts.join('; ')
+    }
+
     // Update stores table (for Web UI display)
     await supabaseAdmin
       .from('stores')
@@ -572,8 +593,8 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
           last_sync_stats: syncStats,
           last_sync_type: 'manual'
         },
-        sync_status: 'completed',
-        sync_error: null
+        sync_status: hasErrors ? 'error' : 'completed',
+        sync_error: errorMessage
       })
       .eq('id', storeId)
 
@@ -597,10 +618,39 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
         })
     }
 
+    // Return error response if sync failed
+    if (hasErrors && totalSynced === 0) {
+      return new Response(
+        JSON.stringify({
+          success: false,
+          error: 'Sync failed',
+          message: errorMessage,
+          stats: syncStats,
+          timestamp: new Date().toISOString()
+        }),
+        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Return partial success if some items synced but there were errors
+    if (hasErrors && totalSynced > 0) {
+      return new Response(
+        JSON.stringify({
+          success: false,
+          error: 'Sync completed with errors',
+          message: errorMessage,
+          stats: syncStats,
+          timestamp: new Date().toISOString()
+        }),
+        { status: 207, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Return success if no errors
     return new Response(
       JSON.stringify({
         success: true,
-        message: 'Sync completed',
+        message: 'Sync completed successfully',
         stats: syncStats,
         timestamp: new Date().toISOString()
       }),