Browse Source

feat: add Qdrant credentials management with per-store override

This commit implements a flexible Qdrant connection management system that allows:
1. Default credentials via Supabase secrets (DEFAULT_QDRANT_URL, DEFAULT_QDRANT_SECRET)
2. Per-store credential override via stores table (qdrant_url, qdrant_api_key)
3. Fallback to hardcoded values if neither is configured

Changes:
- Added qdrant_url and qdrant_api_key columns to stores table
- Modified qdrant-client.ts to accept optional QdrantConfig parameter
- Updated all Qdrant client functions to support per-store config
- Modified shopify-sync to fetch and pass store-specific Qdrant credentials
- Added migration 20251124_qdrant_credentials.sql

Default Qdrant settings (configured in Supabase Dashboard → Project Settings):
- DEFAULT_QDRANT_URL: https://qdrant-1.shop.static.shopcall.ai:6334
- DEFAULT_QDRANT_SECRET: (service key from Qdrant)

Priority order for Qdrant connection:
1. Per-store credentials (if set in stores table)
2. Environment variables (Supabase secrets)
3. Fallback constants (for development)

Note: woocommerce-sync and shoprenter-sync still need similar updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 4 months ago
parent
commit
825b023451

+ 80 - 34
supabase/functions/_shared/qdrant-client.ts

@@ -6,15 +6,30 @@
  *
  * Vector dimensions: 3072 (OpenAI text-embedding-3-large)
  * Metric: Cosine (best for normalized embeddings)
+ *
+ * Connection details can be:
+ * 1. Provided per-store (from stores.qdrant_url and stores.qdrant_api_key)
+ * 2. Fall back to Supabase secrets (DEFAULT_QDRANT_URL, DEFAULT_QDRANT_SECRET)
+ * 3. Fall back to hardcoded defaults if neither is available
  */
 
 import { cleanHtmlContent } from './html-cleaner.ts';
 
-const QDRANT_URL = 'http://142.93.100.6:6333';
-const QDRANT_API_KEY = 'pyXAyyEPbLzba2RvdBwm';
+// Default fallback values (used only if environment variables are not set)
+const FALLBACK_QDRANT_URL = 'http://142.93.100.6:6333';
+const FALLBACK_QDRANT_API_KEY = 'pyXAyyEPbLzba2RvdBwm';
+
 const VECTOR_SIZE = 3072;
 const DISTANCE_METRIC = 'Cosine'; // Best for text embeddings
 
+/**
+ * Qdrant connection configuration
+ */
+export interface QdrantConfig {
+  url?: string;
+  apiKey?: string;
+}
+
 export interface QdrantPoint {
   id: string | number;
   vector: number[];
@@ -31,22 +46,44 @@ export interface CollectionSchema {
   };
 }
 
+/**
+ * Get Qdrant connection details from environment or config
+ */
+function getQdrantConnection(config?: QdrantConfig): { url: string; apiKey: string } {
+  // Priority order:
+  // 1. Per-store config (if provided)
+  // 2. Environment variables (Supabase secrets)
+  // 3. Fallback constants
+
+  const url = config?.url
+    || Deno.env.get('DEFAULT_QDRANT_URL')
+    || FALLBACK_QDRANT_URL;
+
+  const apiKey = config?.apiKey
+    || Deno.env.get('DEFAULT_QDRANT_SECRET')
+    || FALLBACK_QDRANT_API_KEY;
+
+  return { url, apiKey };
+}
+
 /**
  * Make a request to Qdrant API
  */
 async function qdrantRequest(
   endpoint: string,
   method: string = 'GET',
-  body?: any
+  body?: any,
+  config?: QdrantConfig
 ): Promise<any> {
-  const url = `${QDRANT_URL}${endpoint}`;
+  const { url: baseUrl, apiKey } = getQdrantConnection(config);
+  const url = `${baseUrl}${endpoint}`;
 
   const headers: Record<string, string> = {
     'Content-Type': 'application/json',
   };
 
-  if (QDRANT_API_KEY) {
-    headers['api-key'] = QDRANT_API_KEY;
+  if (apiKey) {
+    headers['api-key'] = apiKey;
   }
 
   const options: RequestInit = {
@@ -71,9 +108,9 @@ async function qdrantRequest(
 /**
  * Check if a collection exists
  */
-export async function collectionExists(collectionName: string): Promise<boolean> {
+export async function collectionExists(collectionName: string, config?: QdrantConfig): Promise<boolean> {
   try {
-    await qdrantRequest(`/collections/${collectionName}`);
+    await qdrantRequest(`/collections/${collectionName}`, 'GET', undefined, config);
     return true;
   } catch (error: any) {
     if (error.message?.includes('404')) {
@@ -88,7 +125,8 @@ export async function collectionExists(collectionName: string): Promise<boolean>
  */
 export async function createCollection(
   collectionName: string,
-  payloadIndexes?: Array<{ field: string; type: string }>
+  payloadIndexes?: Array<{ field: string; type: string }>,
+  config?: QdrantConfig
 ): Promise<void> {
   console.log(`[Qdrant] Creating collection: ${collectionName}`);
 
@@ -104,7 +142,7 @@ export async function createCollection(
     on_disk_payload: true,
   };
 
-  await qdrantRequest(`/collections/${collectionName}`, 'PUT', collectionConfig);
+  await qdrantRequest(`/collections/${collectionName}`, 'PUT', collectionConfig, config);
 
   // Create payload indexes for faster filtering
   if (payloadIndexes && payloadIndexes.length > 0) {
@@ -116,7 +154,8 @@ export async function createCollection(
           {
             field_name: index.field,
             field_schema: index.type,
-          }
+          },
+          config
         );
         console.log(`[Qdrant] Created index on ${index.field} (${index.type})`);
       } catch (error) {
@@ -131,9 +170,9 @@ export async function createCollection(
 /**
  * Delete a collection
  */
-export async function deleteCollection(collectionName: string): Promise<void> {
+export async function deleteCollection(collectionName: string, config?: QdrantConfig): Promise<void> {
   console.log(`[Qdrant] Deleting collection: ${collectionName}`);
-  await qdrantRequest(`/collections/${collectionName}`, 'DELETE');
+  await qdrantRequest(`/collections/${collectionName}`, 'DELETE', undefined, config);
   console.log(`[Qdrant] Collection ${collectionName} deleted`);
 }
 
@@ -142,7 +181,8 @@ export async function deleteCollection(collectionName: string): Promise<void> {
  */
 export async function upsertPoints(
   collectionName: string,
-  points: QdrantPoint[]
+  points: QdrantPoint[],
+  config?: QdrantConfig
 ): Promise<void> {
   if (points.length === 0) {
     return;
@@ -157,7 +197,7 @@ export async function upsertPoints(
 
     await qdrantRequest(`/collections/${collectionName}/points`, 'PUT', {
       points: chunk,
-    });
+    }, config);
 
     console.log(`[Qdrant] Upserted ${chunk.length} points (${i + chunk.length}/${points.length})`);
   }
@@ -168,7 +208,8 @@ export async function upsertPoints(
  */
 export async function deletePoints(
   collectionName: string,
-  pointIds: (string | number)[]
+  pointIds: (string | number)[],
+  config?: QdrantConfig
 ): Promise<void> {
   if (pointIds.length === 0) {
     return;
@@ -178,7 +219,7 @@ export async function deletePoints(
 
   await qdrantRequest(`/collections/${collectionName}/points/delete`, 'POST', {
     points: pointIds,
-  });
+  }, config);
 
   console.log(`[Qdrant] Deleted ${pointIds.length} points`);
 }
@@ -188,13 +229,14 @@ export async function deletePoints(
  */
 export async function deletePointsByFilter(
   collectionName: string,
-  filter: any
+  filter: any,
+  config?: QdrantConfig
 ): Promise<void> {
   console.log(`[Qdrant] Deleting points by filter from ${collectionName}`);
 
   await qdrantRequest(`/collections/${collectionName}/points/delete`, 'POST', {
     filter,
-  });
+  }, config);
 
   console.log(`[Qdrant] Points deleted by filter`);
 }
@@ -204,7 +246,8 @@ export async function deletePointsByFilter(
  */
 export async function getPoints(
   collectionName: string,
-  pointIds: (string | number)[]
+  pointIds: (string | number)[],
+  config?: QdrantConfig
 ): Promise<any[]> {
   if (pointIds.length === 0) {
     return [];
@@ -214,7 +257,7 @@ export async function getPoints(
     ids: pointIds,
     with_payload: true,
     with_vector: false,
-  });
+  }, config);
 
   return response.result || [];
 }
@@ -225,7 +268,8 @@ export async function getPoints(
 export async function scrollPoints(
   collectionName: string,
   filter?: any,
-  limit: number = 100
+  limit: number = 100,
+  config?: QdrantConfig
 ): Promise<{ points: any[]; nextOffset?: string }> {
   const body: any = {
     limit,
@@ -237,7 +281,7 @@ export async function scrollPoints(
     body.filter = filter;
   }
 
-  const response = await qdrantRequest(`/collections/${collectionName}/points/scroll`, 'POST', body);
+  const response = await qdrantRequest(`/collections/${collectionName}/points/scroll`, 'POST', body, config);
 
   return {
     points: response.result?.points || [],
@@ -252,7 +296,8 @@ export async function searchPoints(
   collectionName: string,
   vector: number[],
   limit: number = 10,
-  filter?: any
+  filter?: any,
+  config?: QdrantConfig
 ): Promise<any[]> {
   const body: any = {
     vector,
@@ -264,7 +309,7 @@ export async function searchPoints(
     body.filter = filter;
   }
 
-  const response = await qdrantRequest(`/collections/${collectionName}/points/search`, 'POST', body);
+  const response = await qdrantRequest(`/collections/${collectionName}/points/search`, 'POST', body, config);
 
   return response.result || [];
 }
@@ -272,8 +317,8 @@ export async function searchPoints(
 /**
  * Get collection info
  */
-export async function getCollectionInfo(collectionName: string): Promise<any> {
-  const response = await qdrantRequest(`/collections/${collectionName}`);
+export async function getCollectionInfo(collectionName: string, config?: QdrantConfig): Promise<any> {
+  const response = await qdrantRequest(`/collections/${collectionName}`, 'GET', undefined, config);
   return response.result;
 }
 
@@ -331,13 +376,14 @@ export function getCollectionName(shopname: string, dataType: 'products' | 'orde
 export async function initializeStoreCollections(
   shopname: string,
   allowOrders: boolean = true,
-  allowCustomers: boolean = true
+  allowCustomers: boolean = true,
+  config?: QdrantConfig
 ): Promise<void> {
   console.log(`[Qdrant] Initializing collections for store: ${shopname}`);
 
   // Always create products collection
   const productsCollection = getCollectionName(shopname, 'products');
-  if (!(await collectionExists(productsCollection))) {
+  if (!(await collectionExists(productsCollection, config))) {
     await createCollection(productsCollection, [
       { field: 'store_id', type: 'keyword' },
       { field: 'product_id', type: 'keyword' },
@@ -345,13 +391,13 @@ export async function initializeStoreCollections(
       { field: 'status', type: 'keyword' },
       { field: 'price', type: 'float' },
       { field: 'sku', type: 'keyword' },
-    ]);
+    ], config);
   }
 
   // Create orders collection if allowed
   if (allowOrders) {
     const ordersCollection = getCollectionName(shopname, 'orders');
-    if (!(await collectionExists(ordersCollection))) {
+    if (!(await collectionExists(ordersCollection, config))) {
       await createCollection(ordersCollection, [
         { field: 'store_id', type: 'keyword' },
         { field: 'order_id', type: 'keyword' },
@@ -364,14 +410,14 @@ export async function initializeStoreCollections(
         { field: 'billing_country', type: 'keyword' },
         { field: 'shipping_city', type: 'keyword' },
         { field: 'shipping_country', type: 'keyword' },
-      ]);
+      ], config);
     }
   }
 
   // Create customers collection if allowed
   if (allowCustomers) {
     const customersCollection = getCollectionName(shopname, 'customers');
-    if (!(await collectionExists(customersCollection))) {
+    if (!(await collectionExists(customersCollection, config))) {
       await createCollection(customersCollection, [
         { field: 'store_id', type: 'keyword' },
         { field: 'customer_id', type: 'keyword' },
@@ -380,7 +426,7 @@ export async function initializeStoreCollections(
         { field: 'phone', type: 'keyword' },
         { field: 'city', type: 'keyword' },
         { field: 'country', type: 'keyword' },
-      ]);
+      ], config);
     }
   }
 

+ 42 - 27
supabase/functions/shopify-sync/index.ts

@@ -23,7 +23,8 @@ import {
   createProductText,
   createOrderText,
   createCustomerText,
-  QdrantPoint
+  QdrantPoint,
+  QdrantConfig
 } from '../_shared/qdrant-client.ts'
 
 const corsHeaders = {
@@ -104,7 +105,8 @@ async function syncProductsToQdrant(
   storeId: string,
   storeName: string,
   products: ShopifyProduct[],
-  supabaseAdmin: any
+  supabaseAdmin: any,
+  config?: QdrantConfig
 ): Promise<{ synced: number; errors: number }> {
   const startTime = new Date()
   const collectionName = getCollectionName(storeName, 'products')
@@ -116,7 +118,7 @@ async function syncProductsToQdrant(
 
   try {
     // Ensure collection exists
-    if (!(await collectionExists(collectionName))) {
+    if (!(await collectionExists(collectionName, config))) {
       await createCollection(collectionName, [
         { field: 'store_id', type: 'keyword' },
         { field: 'product_id', type: 'keyword' },
@@ -124,13 +126,13 @@ async function syncProductsToQdrant(
         { field: 'status', type: 'keyword' },
         { field: 'price', type: 'float' },
         { field: 'sku', type: 'keyword' },
-      ])
+      ], config)
     }
 
     // Get existing product IDs from Qdrant to detect deletions
     const existingPoints = await scrollPoints(collectionName, {
       must: [{ key: 'store_id', match: { value: storeId } }]
-    }, 1000)
+    }, 1000, config)
 
     const existingProductIds = new Set(
       existingPoints.points.map((p: any) => p.payload?.product_id).filter(Boolean)
@@ -152,7 +154,7 @@ async function syncProductsToQdrant(
           { key: 'store_id', match: { value: storeId } },
           { key: 'product_id', match: { any: deletedProductIds } }
         ]
-      })
+      }, config)
     }
 
     // Generate text representations for all products
@@ -256,7 +258,7 @@ async function syncProductsToQdrant(
     })
 
     // Upsert to Qdrant
-    await upsertPoints(collectionName, points)
+    await upsertPoints(collectionName, points, config)
     synced = points.length
 
     await logQdrantSync(
@@ -297,7 +299,8 @@ async function syncOrdersToQdrant(
   storeId: string,
   storeName: string,
   orders: ShopifyOrder[],
-  supabaseAdmin: any
+  supabaseAdmin: any,
+  config?: QdrantConfig
 ): Promise<{ synced: number; errors: number }> {
   const startTime = new Date()
   const collectionName = getCollectionName(storeName, 'orders')
@@ -308,7 +311,7 @@ async function syncOrdersToQdrant(
   let errors = 0
 
   try {
-    if (!(await collectionExists(collectionName))) {
+    if (!(await collectionExists(collectionName, config))) {
       await createCollection(collectionName, [
         { field: 'store_id', type: 'keyword' },
         { field: 'order_id', type: 'keyword' },
@@ -316,7 +319,7 @@ async function syncOrdersToQdrant(
         { field: 'financial_status', type: 'keyword' },
         { field: 'total_price', type: 'float' },
         { field: 'customer_email', type: 'keyword' },
-      ])
+      ], config)
     }
 
     // Generate text representations for all orders
@@ -375,7 +378,7 @@ async function syncOrdersToQdrant(
       }
     }))
 
-    await upsertPoints(collectionName, points)
+    await upsertPoints(collectionName, points, config)
     synced = points.length
 
     await logQdrantSync(
@@ -416,7 +419,8 @@ async function syncCustomersToQdrant(
   storeId: string,
   storeName: string,
   customers: ShopifyCustomer[],
-  supabaseAdmin: any
+  supabaseAdmin: any,
+  config?: QdrantConfig
 ): Promise<{ synced: number; errors: number }> {
   const startTime = new Date()
   const collectionName = getCollectionName(storeName, 'customers')
@@ -427,13 +431,13 @@ async function syncCustomersToQdrant(
   let errors = 0
 
   try {
-    if (!(await collectionExists(collectionName))) {
+    if (!(await collectionExists(collectionName, config))) {
       await createCollection(collectionName, [
         { field: 'store_id', type: 'keyword' },
         { field: 'customer_id', type: 'keyword' },
         { field: 'platform', type: 'keyword' },
         { field: 'email', type: 'keyword' },
-      ])
+      ], config)
     }
 
     // Generate text representations for all customers
@@ -485,7 +489,7 @@ async function syncCustomersToQdrant(
       }
     }))
 
-    await upsertPoints(collectionName, points)
+    await upsertPoints(collectionName, points, config)
     synced = points.length
 
     await logQdrantSync(
@@ -527,7 +531,8 @@ async function syncProducts(
   storeName: string,
   supabaseAdmin: any,
   qdrantEnabled: boolean,
-  canSyncProducts: boolean
+  canSyncProducts: boolean,
+  qdrantConfig?: QdrantConfig
 ): Promise<{ synced: number; errors: number; qdrant?: { synced: number; errors: number } }> {
   console.log('[Shopify] Syncing products...')
   let synced = 0
@@ -666,7 +671,7 @@ async function syncProducts(
       }
 
       // Sync remaining (non-excluded) products to Qdrant
-      qdrantResult = await syncProductsToQdrant(storeId, storeName, productsForQdrant, supabaseAdmin)
+      qdrantResult = await syncProductsToQdrant(storeId, storeName, productsForQdrant, supabaseAdmin, qdrantConfig)
       console.log(`[Shopify] Qdrant sync complete: ${qdrantResult.synced} synced, ${qdrantResult.errors} errors, ${excludedProductIds.length} excluded`)
     }
 
@@ -685,7 +690,8 @@ async function syncOrders(
   supabaseAdmin: any,
   countryCode: string,
   qdrantEnabled: boolean,
-  canSyncOrders: boolean
+  canSyncOrders: boolean,
+  qdrantConfig?: QdrantConfig
 ): Promise<{ synced: number; errors: number; qdrant?: { synced: number; errors: number } }> {
   console.log('[Shopify] Syncing orders...')
   let synced = 0
@@ -760,7 +766,7 @@ async function syncOrders(
     // Sync to Qdrant if enabled
     let qdrantResult
     if (qdrantEnabled) {
-      qdrantResult = await syncOrdersToQdrant(storeId, storeName, orders, supabaseAdmin)
+      qdrantResult = await syncOrdersToQdrant(storeId, storeName, orders, supabaseAdmin, qdrantConfig)
     }
 
     return { synced, errors, qdrant: qdrantResult }
@@ -778,7 +784,8 @@ async function syncCustomers(
   supabaseAdmin: any,
   countryCode: string,
   qdrantEnabled: boolean,
-  canSyncCustomers: boolean
+  canSyncCustomers: boolean,
+  qdrantConfig?: QdrantConfig
 ): Promise<{ synced: number; errors: number; qdrant?: { synced: number; errors: number } }> {
   console.log('[Shopify] Syncing customers...')
   let synced = 0
@@ -848,7 +855,7 @@ async function syncCustomers(
     // Sync to Qdrant if enabled
     let qdrantResult
     if (qdrantEnabled) {
-      qdrantResult = await syncCustomersToQdrant(storeId, storeName, customers, supabaseAdmin)
+      qdrantResult = await syncCustomersToQdrant(storeId, storeName, customers, supabaseAdmin, qdrantConfig)
     }
 
     return { synced, errors, qdrant: qdrantResult }
@@ -905,7 +912,7 @@ serve(wrapHandler('shopify-sync', async (req) => {
 
     const { data: store, error: storeError } = await supabaseAdmin
       .from('stores')
-      .select('id, platform_name, store_name, store_url, qdrant_sync_enabled')
+      .select('id, platform_name, store_name, store_url, qdrant_sync_enabled, qdrant_url, qdrant_api_key')
       .eq('id', storeId)
       .eq('user_id', user.id)
       .eq('platform_name', 'shopify')
@@ -932,6 +939,12 @@ serve(wrapHandler('shopify-sync', async (req) => {
     const canSyncCustomers = syncConfig?.customers_access_policy === 'sync'
     const qdrantEnabled = store.qdrant_sync_enabled !== false
 
+    // Prepare Qdrant configuration (per-store override or defaults from environment)
+    const qdrantConfig = (store.qdrant_url || store.qdrant_api_key) ? {
+      url: store.qdrant_url || undefined,
+      apiKey: store.qdrant_api_key || undefined,
+    } : undefined
+
     console.log('[Shopify] Sync permissions:', {
       products: canSyncProducts,
       productsPolicy: syncConfig?.products_access_policy || 'not_configured',
@@ -939,7 +952,8 @@ serve(wrapHandler('shopify-sync', async (req) => {
       ordersPolicy: syncConfig?.orders_access_policy || 'not_configured',
       customers: canSyncCustomers,
       customersPolicy: syncConfig?.customers_access_policy || 'not_configured',
-      qdrant: qdrantEnabled
+      qdrant: qdrantEnabled,
+      qdrantCustomConfig: qdrantConfig ? 'yes' : 'no (using defaults)'
     })
 
     // Initialize Qdrant collections if enabled
@@ -948,7 +962,8 @@ serve(wrapHandler('shopify-sync', async (req) => {
         await initializeStoreCollections(
           store.store_name,
           canSyncOrders,
-          canSyncCustomers
+          canSyncCustomers,
+          qdrantConfig
         )
       } catch (error) {
         console.error('[Qdrant] Failed to initialize collections:', error)
@@ -970,15 +985,15 @@ serve(wrapHandler('shopify-sync', async (req) => {
 
     // Perform sync based on type
     if (syncType === 'all' || syncType === 'products') {
-      results.products = await syncProducts(storeId, store.store_name, supabaseAdmin, qdrantEnabled, canSyncProducts)
+      results.products = await syncProducts(storeId, store.store_name, supabaseAdmin, qdrantEnabled, canSyncProducts, qdrantConfig)
     }
 
     if (syncType === 'all' || syncType === 'orders') {
-      results.orders = await syncOrders(storeId, store.store_name, supabaseAdmin, countryCode, qdrantEnabled, canSyncOrders)
+      results.orders = await syncOrders(storeId, store.store_name, supabaseAdmin, countryCode, qdrantEnabled, canSyncOrders, qdrantConfig)
     }
 
     if (syncType === 'all' || syncType === 'customers') {
-      results.customers = await syncCustomers(storeId, store.store_name, supabaseAdmin, countryCode, qdrantEnabled, canSyncCustomers)
+      results.customers = await syncCustomers(storeId, store.store_name, supabaseAdmin, countryCode, qdrantEnabled, canSyncCustomers, qdrantConfig)
     }
 
     results.completed_at = new Date().toISOString()

+ 15 - 0
supabase/migrations/20251124_qdrant_credentials.sql

@@ -0,0 +1,15 @@
+-- Migration: Add per-store Qdrant credentials with fallback to default
+-- Date: 2025-11-24
+-- Description: Allow stores to override default Qdrant connection settings
+
+-- Add Qdrant connection fields to stores table
+ALTER TABLE stores
+  ADD COLUMN IF NOT EXISTS qdrant_url text,
+  ADD COLUMN IF NOT EXISTS qdrant_api_key text;
+
+-- Add comments
+COMMENT ON COLUMN stores.qdrant_url IS 'Custom Qdrant server URL for this store. If NULL, uses DEFAULT_QDRANT_URL from Supabase secrets (https://qdrant-1.shop.static.shopcall.ai:6334)';
+COMMENT ON COLUMN stores.qdrant_api_key IS 'Custom Qdrant API key for this store. If NULL, uses DEFAULT_QDRANT_SECRET from Supabase secrets';
+
+-- Create index for stores with custom Qdrant settings
+CREATE INDEX IF NOT EXISTS idx_stores_custom_qdrant ON stores(id) WHERE qdrant_url IS NOT NULL OR qdrant_api_key IS NOT NULL;