Просмотр исходного кода

feat: add auto-sync on webshop registration with manual sync support #21

Implemented automatic data synchronization when a new webshop is registered, with prevention of concurrent sync jobs and manual sync capability.

Database:
- Added sync_status tracking columns to stores table (idle, syncing, completed, error)
- Added sync_started_at, sync_completed_at, and sync_error columns

Backend:
- Created trigger-sync Edge Function to handle sync orchestration for all platforms
- Updated oauth-shopify to trigger auto-sync after store connection
- Updated oauth-woocommerce to trigger auto-sync after store connection
- Added /api/stores/finalize-shoprenter endpoint with auto-sync for ShopRenter
- Added GET /api/stores/:id/sync-status endpoint
- Added POST /api/stores/:id/sync endpoint for manual sync triggering
- Implemented concurrent sync prevention at multiple levels

Frontend:
- Added sync status column to webshops table
- Added visual sync status badges (syncing, synced, error, not synced)
- Added manual sync button with disabled state during active sync
- Shows last sync timestamp and error messages
- Toast notifications for sync operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 5 месяцев назад
Родитель
Сommit
688b43d3ff

+ 101 - 0
shopcall.ai-main/src/components/IntegrationsContent.tsx

@@ -21,6 +21,10 @@ interface ConnectedStore {
   is_active: boolean | null;
   is_active: boolean | null;
   phone_number: string | null;
   phone_number: string | null;
   created_at: string | null;
   created_at: string | null;
+  sync_status?: string | null;
+  sync_started_at?: string | null;
+  sync_completed_at?: string | null;
+  sync_error?: string | null;
   alt_data?: {
   alt_data?: {
     wcVersion?: string;
     wcVersion?: string;
     wpVersion?: string;
     wpVersion?: string;
@@ -208,6 +212,73 @@ export function IntegrationsContent() {
     }
     }
   };
   };
 
 
+  const handleManualSync = async (storeId: string, storeName: string) => {
+    try {
+      const sessionData = localStorage.getItem('session_data');
+      if (!sessionData) {
+        throw new Error('No session data found');
+      }
+
+      const session = JSON.parse(sessionData);
+      const response = await fetch(`${API_URL}/api/stores/${storeId}/sync`, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${session.session.access_token}`,
+          'Content-Type': 'application/json'
+        }
+      });
+
+      const data = await response.json();
+
+      if (!response.ok) {
+        if (response.status === 409) {
+          toast({
+            title: "Sync Already Running",
+            description: "A sync is already in progress for this store.",
+            variant: "default",
+          });
+          return;
+        }
+        throw new Error(data.error || 'Failed to trigger sync');
+      }
+
+      toast({
+        title: "Sync Started",
+        description: `Data synchronization started for ${storeName}`,
+      });
+
+      // Refresh stores list to show updated sync status
+      setTimeout(() => fetchStores(), 1000);
+    } catch (error) {
+      console.error('Error triggering sync:', error);
+      toast({
+        title: "Sync Failed",
+        description: "Failed to start data synchronization. Please try again.",
+        variant: "destructive",
+      });
+    }
+  };
+
+  const getSyncStatusBadge = (shop: ConnectedStore) => {
+    const status = shop.sync_status || 'idle';
+
+    switch (status) {
+      case 'syncing':
+        return (
+          <Badge className="bg-blue-500 text-white animate-pulse">
+            <Loader2 className="w-3 h-3 mr-1 animate-spin" />
+            Syncing
+          </Badge>
+        );
+      case 'completed':
+        return <Badge className="bg-green-500 text-white">Synced</Badge>;
+      case 'error':
+        return <Badge className="bg-red-500 text-white">Error</Badge>;
+      default:
+        return <Badge className="bg-gray-500 text-white">Not Synced</Badge>;
+    }
+  };
+
   return (
   return (
     <div className="flex-1 space-y-6 p-8 bg-slate-900">
     <div className="flex-1 space-y-6 p-8 bg-slate-900">
       <div className="flex items-center justify-between">
       <div className="flex items-center justify-between">
@@ -379,6 +450,7 @@ export function IntegrationsContent() {
                       <th className="p-4 text-slate-300 font-medium">Store</th>
                       <th className="p-4 text-slate-300 font-medium">Store</th>
                       <th className="p-4 text-slate-300 font-medium">Phone Number</th>
                       <th className="p-4 text-slate-300 font-medium">Phone Number</th>
                       <th className="p-4 text-slate-300 font-medium">Platform</th>
                       <th className="p-4 text-slate-300 font-medium">Platform</th>
+                      <th className="p-4 text-slate-300 font-medium">Sync Status</th>
                       <th className="p-4 text-slate-300 font-medium">Created</th>
                       <th className="p-4 text-slate-300 font-medium">Created</th>
                       <th className="p-4 text-slate-300 font-medium">Status</th>
                       <th className="p-4 text-slate-300 font-medium">Status</th>
                       <th className="p-4 text-slate-300 font-medium">Actions</th>
                       <th className="p-4 text-slate-300 font-medium">Actions</th>
@@ -429,6 +501,21 @@ export function IntegrationsContent() {
                             {shop.platform_name}
                             {shop.platform_name}
                           </Badge>
                           </Badge>
                         </td>
                         </td>
+                        <td className="p-4">
+                          <div className="flex flex-col gap-2">
+                            {getSyncStatusBadge(shop)}
+                            {shop.sync_error && (
+                              <div className="text-xs text-red-400 max-w-[200px] truncate" title={shop.sync_error}>
+                                {shop.sync_error}
+                              </div>
+                            )}
+                            {shop.sync_completed_at && shop.sync_status === 'completed' && (
+                              <div className="text-xs text-slate-500">
+                                {new Date(shop.sync_completed_at).toLocaleString()}
+                              </div>
+                            )}
+                          </div>
+                        </td>
                         <td className="p-4">
                         <td className="p-4">
                           <div className="text-sm text-slate-400">
                           <div className="text-sm text-slate-400">
                             {shop.created_at ? new Date(shop.created_at).toLocaleDateString() : '-'}
                             {shop.created_at ? new Date(shop.created_at).toLocaleDateString() : '-'}
@@ -441,6 +528,20 @@ export function IntegrationsContent() {
                         </td>
                         </td>
                         <td className="p-4">
                         <td className="p-4">
                           <div className="flex items-center gap-2">
                           <div className="flex items-center gap-2">
+                            <Button
+                              size="sm"
+                              variant="outline"
+                              className="text-cyan-500 border-cyan-500 hover:bg-cyan-500/10"
+                              onClick={() => handleManualSync(shop.id, shop.store_name || 'this store')}
+                              disabled={shop.sync_status === 'syncing'}
+                              title={shop.sync_status === 'syncing' ? 'Sync already in progress' : 'Sync store data'}
+                            >
+                              {shop.sync_status === 'syncing' ? (
+                                <Loader2 className="w-4 h-4 animate-spin" />
+                              ) : (
+                                <Zap className="w-4 h-4" />
+                              )}
+                            </Button>
                             <Button
                             <Button
                               size="sm"
                               size="sm"
                               className="bg-cyan-500 hover:bg-cyan-600 text-white"
                               className="bg-cyan-500 hover:bg-cyan-600 text-white"

+ 189 - 0
supabase/functions/api/index.ts

@@ -72,6 +72,195 @@ serve(async (req) => {
       )
       )
     }
     }
 
 
+    // POST /api/stores/finalize-shoprenter - Finalize ShopRenter installation
+    if (path === 'stores/finalize-shoprenter' && req.method === 'POST') {
+      const { installation_id } = await req.json()
+
+      if (!installation_id) {
+        return new Response(
+          JSON.stringify({ error: 'installation_id is required' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+      const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
+
+      // Get pending installation
+      const { data: installation, error: installError } = await supabaseAdmin
+        .from('pending_shoprenter_installs')
+        .select('*')
+        .eq('installation_id', installation_id)
+        .single()
+
+      if (installError || !installation) {
+        return new Response(
+          JSON.stringify({ error: 'Installation not found or expired' }),
+          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Check if expired
+      if (new Date(installation.expires_at) < new Date()) {
+        await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
+        return new Response(
+          JSON.stringify({ error: 'Installation expired. Please try again.' }),
+          { status: 410, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Create store record
+      const { data: newStore, error: storeError } = await supabaseAdmin
+        .from('stores')
+        .insert({
+          user_id: user.id,
+          platform_name: 'shoprenter',
+          store_name: installation.shopname,
+          store_url: `https://${installation.shopname}.shoprenter.hu`,
+          api_key: installation.access_token,
+          api_secret: installation.refresh_token,
+          scopes: installation.scopes || [],
+          alt_data: {
+            token_type: installation.token_type,
+            expires_in: installation.expires_in,
+            connectedAt: new Date().toISOString()
+          }
+        })
+        .select('id')
+        .single()
+
+      if (storeError || !newStore) {
+        console.error('[API] Error creating store:', storeError)
+        return new Response(
+          JSON.stringify({ error: 'Failed to save store credentials' }),
+          { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Delete pending installation
+      await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
+
+      console.log(`[API] ShopRenter store finalized: ${installation.shopname}`)
+
+      // Trigger auto-sync in background
+      const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
+      fetch(triggerSyncUrl, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${supabaseServiceKey}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ store_id: newStore.id })
+      })
+        .then(() => console.log(`[API] Auto-sync triggered for ShopRenter store ${newStore.id}`))
+        .catch(err => console.error(`[API] Failed to trigger auto-sync:`, err))
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          message: 'Store connected successfully',
+          store_id: newStore.id
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // GET /api/stores/:id/sync-status - Get sync status for a store
+    if (path.match(/^stores\/[^\/]+\/sync-status$/) && req.method === 'GET') {
+      const storeId = path.split('/')[1]
+
+      const { data: store, error: storeError } = await supabase
+        .from('stores')
+        .select('sync_status, sync_started_at, sync_completed_at, sync_error')
+        .eq('id', storeId)
+        .eq('user_id', user.id)
+        .single()
+
+      if (storeError || !store) {
+        return new Response(
+          JSON.stringify({ error: 'Store not found or access denied' }),
+          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          status: store.sync_status || 'idle',
+          started_at: store.sync_started_at,
+          completed_at: store.sync_completed_at,
+          error: store.sync_error
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // POST /api/stores/:id/sync - Trigger manual sync for a store
+    if (path.match(/^stores\/[^\/]+\/sync$/) && req.method === 'POST') {
+      const storeId = path.split('/')[1]
+
+      // Verify store ownership
+      const { data: store, error: storeError } = await supabase
+        .from('stores')
+        .select('id, platform_name, sync_status')
+        .eq('id', storeId)
+        .eq('user_id', user.id)
+        .single()
+
+      if (storeError || !store) {
+        return new Response(
+          JSON.stringify({ error: 'Store not found or access denied' }),
+          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Check if sync is already running
+      if (store.sync_status === 'syncing') {
+        return new Response(
+          JSON.stringify({
+            success: false,
+            error: 'Sync already in progress',
+            status: 'syncing'
+          }),
+          { status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Trigger sync via trigger-sync function
+      const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+      const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
+
+      const syncResponse = await fetch(triggerSyncUrl, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${supabaseServiceKey}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ store_id: storeId })
+      })
+
+      const syncResult = await syncResponse.json()
+
+      if (!syncResponse.ok) {
+        return new Response(
+          JSON.stringify({
+            success: false,
+            error: syncResult.error || 'Failed to trigger sync'
+          }),
+          { status: syncResponse.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          message: 'Sync triggered successfully',
+          status: 'syncing'
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
     // DELETE /api/stores/{id} - Delete a store
     // DELETE /api/stores/{id} - Delete a store
     if (path.startsWith('stores/') && req.method === 'DELETE') {
     if (path.startsWith('stores/') && req.method === 'DELETE') {
       const storeId = path.replace('stores/', '')
       const storeId = path.replace('stores/', '')

+ 17 - 2
supabase/functions/oauth-shopify/index.ts

@@ -379,7 +379,7 @@ serve(async (req) => {
       const shopDomain = validation.normalized!
       const shopDomain = validation.normalized!
 
 
       // Store credentials in database
       // Store credentials in database
-      const { error: insertError } = await supabaseAdmin
+      const { data: insertedStore, error: insertError } = await supabaseAdmin
         .from('stores')
         .from('stores')
         .insert({
         .insert({
           user_id: stateData.user_id,
           user_id: stateData.user_id,
@@ -398,11 +398,13 @@ serve(async (req) => {
             connectedAt: new Date().toISOString()
             connectedAt: new Date().toISOString()
           }
           }
         })
         })
+        .select('id')
+        .single()
 
 
       // Clean up state
       // Clean up state
       await supabaseAdmin.from('oauth_states').delete().eq('state', state)
       await supabaseAdmin.from('oauth_states').delete().eq('state', state)
 
 
-      if (insertError) {
+      if (insertError || !insertedStore) {
         console.error('[Shopify] Error storing credentials:', insertError)
         console.error('[Shopify] Error storing credentials:', insertError)
         return new Response(null, {
         return new Response(null, {
           status: 302,
           status: 302,
@@ -415,6 +417,19 @@ serve(async (req) => {
 
 
       console.log(`[Shopify] Store connected successfully: ${storeName} (${shopDomain})`)
       console.log(`[Shopify] Store connected successfully: ${storeName} (${shopDomain})`)
 
 
+      // Trigger auto-sync in background
+      const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
+      fetch(triggerSyncUrl, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${supabaseServiceKey}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ store_id: insertedStore.id })
+      })
+        .then(() => console.log(`[Shopify] Auto-sync triggered for store ${insertedStore.id}`))
+        .catch(err => console.error(`[Shopify] Failed to trigger auto-sync:`, err))
+
       // Redirect back to frontend with success
       // Redirect back to frontend with success
       return new Response(null, {
       return new Response(null, {
         status: 302,
         status: 302,

+ 17 - 2
supabase/functions/oauth-woocommerce/index.ts

@@ -199,7 +199,7 @@ serve(async (req) => {
       const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
       const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
 
 
       // Store credentials in database
       // Store credentials in database
-      const { error: insertError } = await supabaseAdmin
+      const { data: insertedStore, error: insertError } = await supabaseAdmin
         .from('stores')
         .from('stores')
         .insert({
         .insert({
           user_id: user.id,
           user_id: user.id,
@@ -217,8 +217,10 @@ serve(async (req) => {
             authMethod: 'manual'
             authMethod: 'manual'
           }
           }
         })
         })
+        .select('id')
+        .single()
 
 
-      if (insertError) {
+      if (insertError || !insertedStore) {
         console.error('[WooCommerce] Error storing credentials:', insertError)
         console.error('[WooCommerce] Error storing credentials:', insertError)
         return new Response(
         return new Response(
           JSON.stringify({ error: 'Failed to save store credentials' }),
           JSON.stringify({ error: 'Failed to save store credentials' }),
@@ -228,6 +230,19 @@ serve(async (req) => {
 
 
       console.log(`[WooCommerce] Store connected successfully (manual): ${storeName}`)
       console.log(`[WooCommerce] Store connected successfully (manual): ${storeName}`)
 
 
+      // Trigger auto-sync in background
+      const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
+      fetch(triggerSyncUrl, {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${supabaseServiceKey}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ store_id: insertedStore.id })
+      })
+        .then(() => console.log(`[WooCommerce] Auto-sync triggered for store ${insertedStore.id}`))
+        .catch(err => console.error(`[WooCommerce] Failed to trigger auto-sync:`, err))
+
       return new Response(
       return new Response(
         JSON.stringify({
         JSON.stringify({
           success: true,
           success: true,

+ 176 - 0
supabase/functions/trigger-sync/index.ts

@@ -0,0 +1,176 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+/**
+ * Trigger sync for a newly registered store
+ * This function initiates a background sync job for products, orders, and customers
+ */
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response('ok', { headers: corsHeaders })
+  }
+
+  try {
+    const { store_id } = await req.json()
+
+    if (!store_id) {
+      return new Response(
+        JSON.stringify({ error: 'store_id is required' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+    const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
+
+    // Get store details
+    const { data: store, error: storeError } = await supabaseAdmin
+      .from('stores')
+      .select('id, platform_name, store_name, sync_status')
+      .eq('id', store_id)
+      .single()
+
+    if (storeError || !store) {
+      console.error('[TriggerSync] Store not found:', storeError)
+      return new Response(
+        JSON.stringify({ error: 'Store not found' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Check if sync is already running
+    if (store.sync_status === 'syncing') {
+      console.log(`[TriggerSync] Sync already in progress for store ${store_id}`)
+      return new Response(
+        JSON.stringify({
+          success: false,
+          message: 'Sync already in progress',
+          status: 'syncing'
+        }),
+        { status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    console.log(`[TriggerSync] Starting sync for ${store.platform_name} store: ${store.store_name} (${store_id})`)
+
+    // Mark sync as started
+    await supabaseAdmin
+      .from('stores')
+      .update({
+        sync_status: 'syncing',
+        sync_started_at: new Date().toISOString(),
+        sync_error: null
+      })
+      .eq('id', store_id)
+
+    // Trigger appropriate sync based on platform
+    let syncUrl: string
+    let syncBody: any
+
+    switch (store.platform_name) {
+      case 'shopify':
+        syncUrl = `${supabaseUrl}/functions/v1/shopify-sync?store_id=${store_id}`
+        break
+      case 'woocommerce':
+        syncUrl = `${supabaseUrl}/functions/v1/woocommerce-sync`
+        syncBody = {
+          store_id,
+          sync_type: 'all',
+          internal_call: true
+        }
+        break
+      case 'shoprenter':
+        syncUrl = `${supabaseUrl}/functions/v1/shoprenter-sync/${store_id}`
+        break
+      default:
+        console.error(`[TriggerSync] Unsupported platform: ${store.platform_name}`)
+        await supabaseAdmin
+          .from('stores')
+          .update({
+            sync_status: 'error',
+            sync_completed_at: new Date().toISOString(),
+            sync_error: `Unsupported platform: ${store.platform_name}`
+          })
+          .eq('id', store_id)
+
+        return new Response(
+          JSON.stringify({ error: `Unsupported platform: ${store.platform_name}` }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+    }
+
+    // Trigger sync in background (don't wait for completion)
+    fetch(syncUrl, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${supabaseServiceKey}`,
+        'Content-Type': 'application/json'
+      },
+      body: syncBody ? JSON.stringify(syncBody) : undefined
+    })
+      .then(async (response) => {
+        const result = await response.json()
+
+        if (response.ok) {
+          console.log(`[TriggerSync] Sync completed successfully for ${store_id}`)
+          await supabaseAdmin
+            .from('stores')
+            .update({
+              sync_status: 'completed',
+              sync_completed_at: new Date().toISOString()
+            })
+            .eq('id', store_id)
+        } else {
+          console.error(`[TriggerSync] Sync failed for ${store_id}:`, result)
+          await supabaseAdmin
+            .from('stores')
+            .update({
+              sync_status: 'error',
+              sync_completed_at: new Date().toISOString(),
+              sync_error: result.error || 'Unknown error during sync'
+            })
+            .eq('id', store_id)
+        }
+      })
+      .catch(async (error) => {
+        console.error(`[TriggerSync] Sync error for ${store_id}:`, error)
+        await supabaseAdmin
+          .from('stores')
+          .update({
+            sync_status: 'error',
+            sync_completed_at: new Date().toISOString(),
+            sync_error: error.message || 'Unknown error during sync'
+          })
+          .eq('id', store_id)
+      })
+
+    console.log(`[TriggerSync] Sync triggered successfully for ${store_id}`)
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        message: 'Sync triggered successfully',
+        store_id,
+        platform: store.platform_name,
+        status: 'syncing'
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+
+  } catch (error) {
+    console.error('[TriggerSync] Error:', error)
+    return new Response(
+      JSON.stringify({
+        error: 'Internal server error',
+        details: error instanceof Error ? error.message : 'Unknown error'
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+  }
+})