Преглед изворни кода

feat: store category ID and name in shoprenter products cache #112

- Updated shoprenter-sync function to extract category IDs and names from API
- Added helper functions to decode base64 category IDs to numeric IDs
- Modified product caching to store categories as [{ id: '206', name: 'Category Name' }]
- Updated API to handle both old and new category formats for backward compatibility
- Enhanced /api/store-data/categories endpoint to work with current data format
- Added transformation layer to ensure consistent category format in UI

This enables the category exclusion functionality in the WebUI where store owners
can exclude entire categories from Qdrant sync while keeping products in database.

Resolves: #112

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

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh пре 4 месеци
родитељ
комит
d0b256c
2 измењених фајлова са 223 додато и 41 уклоњено
  1. 116 17
      supabase/functions/api/index.ts
  2. 107 24
      supabase/functions/shoprenter-sync/index.ts

+ 116 - 17
supabase/functions/api/index.ts

@@ -1090,6 +1090,58 @@ serve(async (req) => {
       }
       }
     }
     }
 
 
+    // Helper functions for category format transformation
+    // =========================================================================
+
+    /**
+     * Extract numeric category ID from base64-encoded category ID
+     * Example: "Y2F0ZWdvcnktY2F0ZWdvcnlfaWQ9MjA2" -> "206"
+     */
+    function extractNumericCategoryId(base64CategoryId: string): string | null {
+      try {
+        const decoded = atob(base64CategoryId)
+        // Format: "category-category_id=206"
+        const match = decoded.match(/category_id=(\d+)/)
+        return match ? match[1] : null
+      } catch (error) {
+        console.error('Failed to decode category ID:', base64CategoryId, error)
+        return null
+      }
+    }
+
+    /**
+     * Transform old category format (base64 strings) to new format (objects with id and name)
+     * If already in new format, return as-is
+     */
+    function transformCategoriesToNewFormat(categories: any[]): Array<{ id: string; name: string }> {
+      if (!categories || categories.length === 0) return []
+
+      return categories.map(cat => {
+        // If already in new format (has id and name properties)
+        if (cat && typeof cat === 'object' && (cat.id || cat.category_id) && cat.name) {
+          return {
+            id: cat.id || cat.category_id,
+            name: cat.name || cat.category_name || 'Unknown Category'
+          }
+        }
+
+        // If it's old format (base64 string)
+        if (typeof cat === 'string') {
+          const numericId = extractNumericCategoryId(cat)
+          return {
+            id: numericId || cat,
+            name: 'Legacy Category ' + (numericId || 'Unknown') // Temporary name until sync updates it
+          }
+        }
+
+        // Fallback for other formats
+        return {
+          id: cat?.id || cat?.category_id || cat || 'unknown',
+          name: cat?.name || cat?.category_name || 'Unknown Category'
+        }
+      }).filter(cat => cat.id) // Remove invalid categories
+    }
+
     // =========================================================================
     // =========================================================================
     // NEW ENDPOINT: GET /api/store-data/categories - Get categories for a store
     // NEW ENDPOINT: GET /api/store-data/categories - Get categories for a store
     // =========================================================================
     // =========================================================================
@@ -1119,25 +1171,62 @@ serve(async (req) => {
       }
       }
 
 
       try {
       try {
-        // Use the helper function to get categories
-        const { data: categories, error: categoriesError } = await supabase
-          .rpc('get_store_categories', {
-            p_store_id: storeId,
-            p_platform: store.platform_name
-          })
+        // Get all products for this store to extract categories
+        const platform = store.platform_name
+        const tableName = platform === 'woocommerce'
+          ? 'woocommerce_products_cache'
+          : platform === 'shopify'
+          ? 'shopify_products_cache'
+          : 'shoprenter_products_cache'
+
+        const { data: products, error: productsError } = await supabase
+          .from(tableName)
+          .select('categories')
+          .eq('store_id', storeId)
 
 
-        if (categoriesError) {
-          console.error('Error fetching categories:', categoriesError)
+        if (productsError) {
+          console.error('Error fetching products for categories:', productsError)
           return new Response(
           return new Response(
-            JSON.stringify({ error: 'Failed to fetch categories' }),
+            JSON.stringify({ error: 'Failed to fetch products' }),
             { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
             { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
           )
           )
         }
         }
 
 
+        // Extract and count categories
+        const categoryMap = new Map<string, { name: string; count: number }>()
+
+        for (const product of products || []) {
+          const transformedCategories = transformCategoriesToNewFormat(product.categories || [])
+          for (const cat of transformedCategories) {
+            if (categoryMap.has(cat.id)) {
+              categoryMap.get(cat.id)!.count++
+            } else {
+              categoryMap.set(cat.id, { name: cat.name, count: 1 })
+            }
+          }
+        }
+
+        // Get excluded categories
+        const { data: excludedCategories } = await supabase
+          .from('excluded_categories')
+          .select('category_id')
+          .eq('store_id', storeId)
+          .eq('platform', platform)
+
+        const excludedCategoryIds = new Set(excludedCategories?.map(ec => ec.category_id) || [])
+
+        // Build response format
+        const categories = Array.from(categoryMap.entries()).map(([categoryId, { name, count }]) => ({
+          category_id: categoryId,
+          category_name: name,
+          product_count: count,
+          is_excluded: excludedCategoryIds.has(categoryId)
+        }))
+
         return new Response(
         return new Response(
           JSON.stringify({
           JSON.stringify({
             success: true,
             success: true,
-            categories: categories || []
+            categories: categories.sort((a, b) => a.category_name.localeCompare(b.category_name))
           }),
           }),
           { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
           { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
         )
         )
@@ -1269,11 +1358,11 @@ serve(async (req) => {
           query = query.or(`name.ilike.%${search}%,sku.ilike.%${search}%`)
           query = query.or(`name.ilike.%${search}%,sku.ilike.%${search}%`)
         }
         }
 
 
-        // Apply category filter
-        if (categoryFilter) {
-          // Filter products that have this category
-          query = query.contains('categories', [{ id: categoryFilter }])
-        }
+        // Apply category filter - disabled temporarily because of format mismatch
+        // Will be re-enabled after sync function deployment updates data format
+        // if (categoryFilter) {
+        //   query = query.contains('categories', [{ id: categoryFilter }])
+        // }
 
 
         const { data: products, error: productsError } = await query
         const { data: products, error: productsError } = await query
 
 
@@ -1318,7 +1407,10 @@ serve(async (req) => {
 
 
         // Transform to response format
         // Transform to response format
         let results = products.map(p => {
         let results = products.map(p => {
-          const excludedByCategory = isExcludedByCategory(p.categories || [])
+          // Transform categories to consistent format
+          const transformedCategories = transformCategoriesToNewFormat(p.categories || [])
+
+          const excludedByCategory = isExcludedByCategory(transformedCategories)
           const excludedByIndividual = p.excluded === true
           const excludedByIndividual = p.excluded === true
           const effectivelyExcluded = excludedByIndividual || excludedByCategory
           const effectivelyExcluded = excludedByIndividual || excludedByCategory
 
 
@@ -1327,7 +1419,7 @@ serve(async (req) => {
             inner_id: p.inner_id, // For ShopRenter
             inner_id: p.inner_id, // For ShopRenter
             name: p.name || '',
             name: p.name || '',
             sku: p.sku || '',
             sku: p.sku || '',
-            categories: p.categories || [],
+            categories: transformedCategories, // Now consistently formatted
             enabled_in_context: !effectivelyExcluded, // Inverted logic: enabled = NOT excluded
             enabled_in_context: !effectivelyExcluded, // Inverted logic: enabled = NOT excluded
             excluded_by_individual: excludedByIndividual,
             excluded_by_individual: excludedByIndividual,
             excluded_by_category: excludedByCategory,
             excluded_by_category: excludedByCategory,
@@ -1335,6 +1427,13 @@ serve(async (req) => {
           }
           }
         })
         })
 
 
+        // Apply category filter after transformation
+        if (categoryFilter && categoryFilter !== 'all') {
+          results = results.filter(r =>
+            r.categories.some(cat => cat.id === categoryFilter)
+          )
+        }
+
         // Apply enabled filter if specified
         // Apply enabled filter if specified
         if (enabledFilter !== null) {
         if (enabledFilter !== null) {
           const filterEnabled = enabledFilter === 'true'
           const filterEnabled = enabledFilter === 'true'

+ 107 - 24
supabase/functions/shoprenter-sync/index.ts

@@ -35,6 +35,46 @@ function extractCategoryId(categoryHref: string): string | null {
   return parts[parts.length - 1] || null
   return parts[parts.length - 1] || null
 }
 }
 
 
+/**
+ * Extract numeric category ID from base64-encoded category ID
+ * Example: "Y2F0ZWdvcnktY2F0ZWdvcnlfaWQ9MjA2" -> "206"
+ */
+function extractNumericCategoryId(base64CategoryId: string): string | null {
+  try {
+    const decoded = atob(base64CategoryId)
+    // Format: "category-category_id=206"
+    const match = decoded.match(/category_id=(\d+)/)
+    return match ? match[1] : null
+  } catch (error) {
+    console.error('Failed to decode category ID:', base64CategoryId, error)
+    return null
+  }
+}
+
+/**
+ * Extract categories from ShopRenter product and return array with ID and name
+ * Returns: [{ id: "206", name: "Category Name" }, ...]
+ */
+async function extractProductCategories(product: any, categoryCache: Map<string, string>): Promise<Array<{ id: string; name: string }>> {
+  const categories: Array<{ id: string; name: string }> = []
+  const categoryRelations = product.productCategoryRelations || []
+
+  for (const rel of categoryRelations) {
+    const categoryId = extractCategoryId(rel.category?.href)
+    if (categoryId) {
+      const numericId = extractNumericCategoryId(categoryId)
+      if (numericId && categoryCache.has(categoryId)) {
+        categories.push({
+          id: numericId,
+          name: categoryCache.get(categoryId)!
+        })
+      }
+    }
+  }
+
+  return categories
+}
+
 /**
 /**
  * Fetch and process category details from ShopRenter API
  * Fetch and process category details from ShopRenter API
  * Returns a text representation: "CategoryName - Description" (HTML cleaned)
  * Returns a text representation: "CategoryName - Description" (HTML cleaned)
@@ -934,35 +974,27 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
         let hasMore = true
         let hasMore = true
         const limit = 50
         const limit = 50
 
 
+        // Build cache of all unique category IDs and names across all products first
+        const allProductsForCache: any[] = []
+        const allUniqueCategories = new Set<string>()
+
         while (hasMore) {
         while (hasMore) {
           const productsData = await fetchProducts(storeId, page, limit)
           const productsData = await fetchProducts(storeId, page, limit)
 
 
           if (productsData.items && productsData.items.length > 0) {
           if (productsData.items && productsData.items.length > 0) {
-            // Collect all products for Qdrant sync
+            // Collect all products for Qdrant sync and cache processing
             allProducts.push(...productsData.items)
             allProducts.push(...productsData.items)
-
-            const productsToCache = productsData.items.map((product: any) => ({
-              store_id: storeId,
-              product_id: product.id,
-              inner_id: product.innerId,
-              name: product.name,
-              sku: product.sku,
-              categories: product.productCategory || [],
-              excluded: false, // Will be set if in exclusion list
-              last_synced_at: new Date().toISOString()
-            }))
-
-            const { error: upsertError } = await supabaseAdmin
-              .from('shoprenter_products_cache')
-              .upsert(productsToCache, {
-                onConflict: 'store_id,product_id'
-              })
-
-            if (upsertError) {
-              console.error('[ShopRenter] Error caching products:', upsertError)
-              syncStats.products.errors += productsToCache.length
-            } else {
-              syncStats.products.synced += productsToCache.length
+            allProductsForCache.push(...productsData.items)
+
+            // Collect all unique category IDs for batch fetching
+            for (const product of productsData.items) {
+              const categoryRelations = product.productCategoryRelations || []
+              for (const rel of categoryRelations) {
+                const categoryId = extractCategoryId(rel.category?.href)
+                if (categoryId) {
+                  allUniqueCategories.add(categoryId)
+                }
+              }
             }
             }
 
 
             // Check if there are more pages
             // Check if there are more pages
@@ -979,6 +1011,57 @@ serve(wrapHandler('shoprenter-sync', async (req) => {
           }
           }
         }
         }
 
 
+        // Now fetch all unique categories in parallel
+        console.log(`[ShopRenter] Fetching ${allUniqueCategories.size} unique categories for cache...`)
+        const categoryCache = new Map<string, string>()
+        if (allUniqueCategories.size > 0) {
+          const categoryPromises = Array.from(allUniqueCategories).map(async (categoryId) => {
+            try {
+              const categoryText = await fetchCategoryText(storeId, categoryId)
+              if (categoryText) {
+                categoryCache.set(categoryId, categoryText)
+              }
+            } catch (error) {
+              console.error(`Failed to fetch category ${categoryId}:`, error)
+            }
+          })
+          await Promise.all(categoryPromises)
+        }
+        console.log(`[ShopRenter] Fetched ${categoryCache.size} categories successfully`)
+
+        // Now process all products and cache them with proper category data
+        const productsToCache = await Promise.all(
+          allProductsForCache.map(async (product: any) => {
+            const categories = await extractProductCategories(product, categoryCache)
+            return {
+              store_id: storeId,
+              product_id: product.id,
+              inner_id: product.innerId,
+              name: product.name,
+              sku: product.sku,
+              categories: categories,  // Now stores [{ id: "206", name: "Category Name" }]
+              excluded: false, // Will be set if in exclusion list
+              last_synced_at: new Date().toISOString()
+            }
+          })
+        )
+
+        // Cache all products at once
+        if (productsToCache.length > 0) {
+          const { error: upsertError } = await supabaseAdmin
+            .from('shoprenter_products_cache')
+            .upsert(productsToCache, {
+              onConflict: 'store_id,product_id'
+            })
+
+          if (upsertError) {
+            console.error('[ShopRenter] Error caching products:', upsertError)
+            syncStats.products.errors += productsToCache.length
+          } else {
+            syncStats.products.synced += productsToCache.length
+          }
+        }
+
         console.log(`[ShopRenter] Products synced: ${syncStats.products.synced}`)
         console.log(`[ShopRenter] Products synced: ${syncStats.products.synced}`)
 
 
         // Sync to Qdrant if enabled
         // Sync to Qdrant if enabled