Sfoglia il codice sorgente

feat: Implement WooCommerce scheduled automatic sync #14

- Add woocommerce-scheduled-sync Edge Function following ShopRenter pattern
- Update woocommerce-sync to support internal calls from scheduled sync
- Add database migration for pg_cron job and helper functions
- Reuse existing store_sync_config and sync_logs tables (platform-agnostic)
- Schedule hourly sync job at minute 5 to avoid overlap with ShopRenter
- Enable sync by default for existing WooCommerce stores
Claude 5 mesi fa
parent
commit
a3833d49a6

+ 270 - 0
supabase/functions/woocommerce-scheduled-sync/index.ts

@@ -0,0 +1,270 @@
+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',
+}
+
+/**
+ * Scheduled Background Sync for WooCommerce Stores
+ *
+ * This Edge Function is designed to be called by pg_cron or external schedulers.
+ * It requires an internal secret key for authentication instead of user tokens.
+ *
+ * Security: Uses INTERNAL_SYNC_SECRET environment variable for authentication
+ */
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response('ok', { headers: corsHeaders })
+  }
+
+  try {
+    // Verify internal secret key for scheduled sync
+    const internalSecret = req.headers.get('x-internal-secret')
+    const expectedSecret = Deno.env.get('INTERNAL_SYNC_SECRET')
+
+    if (!expectedSecret) {
+      console.error('[WooCommerce Scheduled Sync] INTERNAL_SYNC_SECRET not configured')
+      return new Response(
+        JSON.stringify({ error: 'Service not configured' }),
+        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    if (!internalSecret || internalSecret !== expectedSecret) {
+      console.warn('[WooCommerce Scheduled Sync] Invalid or missing internal secret')
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, 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 sync frequency from request (set by pg_cron job)
+    const body = await req.json().catch(() => ({}))
+    const frequency = body.frequency || 'all' // Default to all frequencies
+
+    // Build query to get WooCommerce stores that need syncing
+    let query = supabaseAdmin
+      .from('stores')
+      .select(`
+        id,
+        user_id,
+        store_name,
+        store_url,
+        alt_data,
+        store_sync_config (
+          enabled,
+          sync_frequency,
+          sync_products,
+          sync_orders,
+          sync_customers,
+          last_sync_at,
+          next_sync_at
+        )
+      `)
+      .eq('platform_name', 'woocommerce')
+
+    // If specific frequency requested, filter by it
+    if (frequency !== 'all') {
+      query = query.eq('store_sync_config.sync_frequency', frequency)
+    }
+
+    const { data: stores, error: storesError } = await query
+
+    if (storesError) {
+      console.error('[WooCommerce Scheduled Sync] Error fetching stores:', storesError)
+      return new Response(
+        JSON.stringify({ error: 'Failed to fetch stores', details: storesError.message }),
+        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Filter stores that have sync enabled and are due for sync
+    const now = new Date()
+    const storesToSync = stores?.filter(store => {
+      const config = store.store_sync_config?.[0]
+      if (!config || !config.enabled) {
+        return false
+      }
+
+      // Check if sync is due
+      if (config.next_sync_at) {
+        const nextSync = new Date(config.next_sync_at)
+        return nextSync <= now
+      }
+
+      // If no next_sync_at, sync is due
+      return true
+    }) || []
+
+    if (storesToSync.length === 0) {
+      console.log('[WooCommerce Scheduled Sync] No WooCommerce stores due for sync')
+      return new Response(
+        JSON.stringify({
+          success: true,
+          message: 'No stores due for sync',
+          stores_processed: 0,
+          frequency: frequency
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    console.log(`[WooCommerce Scheduled Sync] Found ${storesToSync.length} stores due for sync`)
+
+    const syncResults = []
+
+    // Sync each store by calling the woocommerce-sync function
+    for (const store of storesToSync) {
+      const storeId = store.id
+      const config = store.store_sync_config?.[0]
+      console.log(`[WooCommerce Scheduled Sync] Starting sync for store ${storeId} (${store.store_name})`)
+
+      const syncStats = {
+        store_id: storeId,
+        store_name: store.store_name,
+        products: { synced: 0, errors: 0 },
+        orders: { synced: 0, errors: 0 },
+        customers: { synced: 0, errors: 0 },
+        started_at: new Date().toISOString(),
+        completed_at: null as string | null,
+        status: 'success' as 'success' | 'partial' | 'failed',
+        error_message: null as string | null
+      }
+
+      try {
+        // Determine what to sync based on store config
+        const syncTypes = []
+        if (config?.sync_products !== false) syncTypes.push('products')
+        if (config?.sync_orders !== false) syncTypes.push('orders')
+        if (config?.sync_customers !== false) syncTypes.push('customers')
+
+        const syncType = syncTypes.length > 0 ? 'all' : 'products'
+
+        // Call woocommerce-sync Edge Function
+        const syncResponse = await fetch(`${supabaseUrl}/functions/v1/woocommerce-sync`, {
+          method: 'POST',
+          headers: {
+            'Authorization': `Bearer ${supabaseServiceKey}`,
+            'Content-Type': 'application/json'
+          },
+          body: JSON.stringify({
+            store_id: storeId,
+            sync_type: syncType,
+            internal_call: true // Flag to bypass user auth
+          })
+        })
+
+        if (!syncResponse.ok) {
+          throw new Error(`Sync failed with status ${syncResponse.status}`)
+        }
+
+        const syncResult = await syncResponse.json()
+
+        if (syncResult.success && syncResult.stats) {
+          syncStats.products = syncResult.stats.products || { synced: 0, errors: 0 }
+          syncStats.orders = syncResult.stats.orders || { synced: 0, errors: 0 }
+          syncStats.customers = syncResult.stats.customers || { synced: 0, errors: 0 }
+
+          // Determine status based on errors
+          const hasErrors = (
+            syncStats.products.errors > 0 ||
+            syncStats.orders.errors > 0 ||
+            syncStats.customers.errors > 0
+          )
+          syncStats.status = hasErrors ? 'partial' : 'success'
+        } else {
+          syncStats.status = 'failed'
+          syncStats.error_message = syncResult.error || 'Unknown error'
+        }
+
+        // Update store_sync_config timestamps
+        if (config) {
+          await supabaseAdmin
+            .from('store_sync_config')
+            .update({
+              last_sync_at: new Date().toISOString(),
+              // next_sync_at will be auto-calculated by trigger
+            })
+            .eq('store_id', storeId)
+        } else {
+          // Create sync config if it doesn't exist
+          await supabaseAdmin
+            .from('store_sync_config')
+            .insert({
+              store_id: storeId,
+              enabled: true,
+              sync_frequency: 'hourly',
+              last_sync_at: new Date().toISOString()
+            })
+        }
+
+        syncStats.completed_at = new Date().toISOString()
+        console.log(`[WooCommerce Scheduled Sync] Store ${storeId} sync completed with status: ${syncStats.status}`)
+
+      } catch (error) {
+        console.error(`[WooCommerce Scheduled Sync] Fatal error syncing store ${storeId}:`, error)
+        syncStats.status = 'failed'
+        syncStats.error_message = error.message
+        syncStats.completed_at = new Date().toISOString()
+      }
+
+      syncResults.push(syncStats)
+    }
+
+    // Log sync summary to database
+    try {
+      await supabaseAdmin
+        .from('sync_logs')
+        .insert({
+          sync_type: 'scheduled',
+          platform: 'woocommerce',
+          stores_processed: storesToSync.length,
+          results: syncResults,
+          started_at: syncResults[0]?.started_at || new Date().toISOString(),
+          completed_at: new Date().toISOString()
+        })
+    } catch (error) {
+      console.error('[WooCommerce Scheduled Sync] Failed to log sync results:', error)
+      // Don't fail the request if logging fails
+    }
+
+    const successCount = syncResults.filter(r => r.status === 'success').length
+    const partialCount = syncResults.filter(r => r.status === 'partial').length
+    const failedCount = syncResults.filter(r => r.status === 'failed').length
+
+    console.log(`[WooCommerce Scheduled Sync] Batch complete: ${successCount} success, ${partialCount} partial, ${failedCount} failed`)
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        message: 'Scheduled sync completed',
+        summary: {
+          stores_processed: storesToSync.length,
+          success: successCount,
+          partial: partialCount,
+          failed: failedCount
+        },
+        results: syncResults,
+        timestamp: new Date().toISOString()
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+
+  } catch (error) {
+    console.error('[WooCommerce Scheduled Sync] Fatal error:', error)
+    return new Response(
+      JSON.stringify({
+        error: 'Scheduled sync failed',
+        details: error.message
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    )
+  }
+})

+ 35 - 22
supabase/functions/woocommerce-sync/index.ts

@@ -373,28 +373,35 @@ serve(async (req) => {
 
     // POST request - Trigger sync
     if (req.method === 'POST') {
-      // Get user from authorization header
-      const authHeader = req.headers.get('authorization')
-      if (!authHeader) {
-        return new Response(
-          JSON.stringify({ error: 'No authorization header' }),
-          { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
-        )
-      }
+      // Parse request body first to check for internal_call flag
+      const body = await req.json()
+      const { store_id, sync_type, internal_call } = body
+
+      let user = null
+
+      // Handle authentication - skip for internal calls from scheduled sync
+      if (internal_call !== true) {
+        // Regular user authentication
+        const authHeader = req.headers.get('authorization')
+        if (!authHeader) {
+          return new Response(
+            JSON.stringify({ error: 'No authorization header' }),
+            { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
 
-      const token = authHeader.replace('Bearer ', '')
-      const { data: { user }, error: userError } = await supabase.auth.getUser(token)
+        const token = authHeader.replace('Bearer ', '')
+        const { data: { user: authUser }, error: userError } = await supabase.auth.getUser(token)
 
-      if (userError || !user) {
-        return new Response(
-          JSON.stringify({ error: 'Invalid token' }),
-          { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
-        )
-      }
+        if (userError || !authUser) {
+          return new Response(
+            JSON.stringify({ error: 'Invalid token' }),
+            { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
 
-      // Parse request body
-      const body = await req.json()
-      const { store_id, sync_type } = body
+        user = authUser
+      }
 
       if (!store_id) {
         return new Response(
@@ -410,13 +417,19 @@ serve(async (req) => {
         )
       }
 
-      // Verify store belongs to user
-      const { data: store, error: storeError } = await supabase
+      // Verify store exists and optionally belongs to user (for non-internal calls)
+      let storeQuery = supabaseAdmin
         .from('stores')
         .select('id, store_name, store_url')
         .eq('id', store_id)
-        .eq('user_id', user.id)
         .eq('platform_name', 'woocommerce')
+
+      // For non-internal calls, verify store belongs to user
+      if (user) {
+        storeQuery = storeQuery.eq('user_id', user.id)
+      }
+
+      const { data: store, error: storeError } = await storeQuery
         .single()
 
       if (storeError || !store) {

+ 118 - 0
supabase/migrations/20251030_woocommerce_scheduled_sync.sql

@@ -0,0 +1,118 @@
+-- Migration: WooCommerce Scheduled Sync Setup
+-- Description: Adds WooCommerce support to existing scheduled sync infrastructure
+-- Date: 2025-10-30
+-- Prerequisites: 20250129_shoprenter_scheduled_sync.sql must be applied first
+
+-- ============================================================================
+-- STEP 1: Create Function to Call WooCommerce Scheduled Sync Edge Function
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION trigger_woocommerce_scheduled_sync()
+RETURNS void AS $$
+DECLARE
+  response_data jsonb;
+  internal_secret TEXT;
+  supabase_url TEXT;
+BEGIN
+  -- Get environment variables (these should be set in Supabase dashboard)
+  internal_secret := current_setting('app.internal_sync_secret', true);
+  supabase_url := current_setting('app.supabase_url', true);
+
+  IF internal_secret IS NULL OR supabase_url IS NULL THEN
+    RAISE WARNING 'Missing required settings for WooCommerce scheduled sync';
+    RETURN;
+  END IF;
+
+  -- Make HTTP request to the WooCommerce scheduled sync Edge Function
+  SELECT INTO response_data
+    net.http_post(
+      url := supabase_url || '/functions/v1/woocommerce-scheduled-sync',
+      headers := jsonb_build_object(
+        'Content-Type', 'application/json',
+        'x-internal-secret', internal_secret
+      ),
+      body := jsonb_build_object('source', 'pg_cron')
+    );
+
+  -- Log the result
+  RAISE NOTICE 'WooCommerce scheduled sync triggered: %', response_data;
+
+EXCEPTION
+  WHEN OTHERS THEN
+    RAISE WARNING 'Error triggering WooCommerce scheduled sync: %', SQLERRM;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- STEP 2: Schedule WooCommerce Sync Job with pg_cron
+-- ============================================================================
+
+-- Remove existing job if it exists
+SELECT cron.unschedule('woocommerce-hourly-sync') WHERE true;
+
+-- Schedule the sync to run every hour (5 minutes after ShopRenter to avoid overlap)
+-- Cron format: minute hour day month weekday
+-- '5 * * * *' = Every hour at minute 5
+SELECT cron.schedule(
+  'woocommerce-hourly-sync',           -- Job name
+  '5 * * * *',                         -- Every hour at minute 5
+  $$ SELECT trigger_woocommerce_scheduled_sync(); $$
+);
+
+-- ============================================================================
+-- STEP 3: Insert Default Sync Configuration for Existing WooCommerce Stores
+-- ============================================================================
+
+-- Create default sync config for all existing WooCommerce stores
+INSERT INTO store_sync_config (store_id, enabled, sync_frequency)
+SELECT
+  id,
+  true,
+  'hourly'
+FROM stores
+WHERE platform_name = 'woocommerce'
+ON CONFLICT (store_id) DO NOTHING;
+
+-- ============================================================================
+-- STEP 4: Create Helper Functions for WooCommerce Sync Status
+-- ============================================================================
+
+-- Function to get WooCommerce sync status for a store
+CREATE OR REPLACE FUNCTION get_woocommerce_sync_status(p_store_id UUID)
+RETURNS TABLE(
+  last_sync_at TIMESTAMPTZ,
+  sync_status TEXT,
+  products_count BIGINT,
+  orders_count BIGINT,
+  customers_count BIGINT
+) AS $$
+BEGIN
+  RETURN QUERY
+  SELECT
+    s.alt_data->>'lastSync' AS last_sync_at,
+    COALESCE(s.alt_data->>'syncStatus', 'idle') AS sync_status,
+    COALESCE((s.alt_data->>'productsCount')::BIGINT, 0) AS products_count,
+    COALESCE((s.alt_data->>'ordersCount')::BIGINT, 0) AS orders_count,
+    COALESCE((s.alt_data->>'customersCount')::BIGINT, 0) AS customers_count
+  FROM stores s
+  WHERE s.id = p_store_id
+    AND s.platform_name = 'woocommerce';
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- Migration Complete
+-- ============================================================================
+
+-- Log migration completion
+DO $$
+BEGIN
+  RAISE NOTICE 'WooCommerce scheduled sync migration completed successfully';
+  RAISE NOTICE 'Hourly sync job scheduled: woocommerce-hourly-sync';
+  RAISE NOTICE 'Next steps:';
+  RAISE NOTICE '1. Ensure INTERNAL_SYNC_SECRET is set in Edge Functions environment';
+  RAISE NOTICE '2. Ensure app.internal_sync_secret is set in Supabase settings';
+  RAISE NOTICE '3. Ensure app.supabase_url is set in Supabase settings';
+  RAISE NOTICE '4. Deploy woocommerce-scheduled-sync Edge Function';
+  RAISE NOTICE '5. Monitor sync_logs table for execution results';
+END $$;