Browse Source

feat: implement international phone number formatting for all platforms #33

- Created phone-formatter utility with E.164 format support
- Auto-detects country codes from store domains (20+ countries)
- Applied formatting to Shopify, WooCommerce, and ShopRenter sync functions
- Handles multiple phone sources (customer, billing, shipping) with fallback
- Validates and formats phone numbers consistently across all platforms
- Supports both manual and scheduled background sync operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 5 months ago
parent
commit
e2abbab53e

+ 160 - 0
supabase/functions/_shared/phone-formatter.ts

@@ -0,0 +1,160 @@
+/**
+ * Phone Number Formatter Utility
+ *
+ * Formats phone numbers to international format (E.164)
+ * Example: +36309284614
+ */
+
+/**
+ * Format a phone number to international format
+ *
+ * @param phoneNumber - The raw phone number string
+ * @param defaultCountryCode - Default country code to use if none is present (e.g., '36' for Hungary)
+ * @returns Formatted phone number in international format (e.g., +36309284614) or null if invalid
+ */
+export function formatPhoneNumber(
+  phoneNumber: string | null | undefined,
+  defaultCountryCode: string = '36'
+): string | null {
+  // Return null for empty/null/undefined values
+  if (!phoneNumber || typeof phoneNumber !== 'string') {
+    return null
+  }
+
+  // Remove all non-digit characters except leading +
+  let cleaned = phoneNumber.trim()
+  const hasPlus = cleaned.startsWith('+')
+  cleaned = cleaned.replace(/[^\d]/g, '')
+
+  // Return null if no digits found
+  if (!cleaned || cleaned.length === 0) {
+    return null
+  }
+
+  // If the number already has a + and digits, return formatted
+  if (hasPlus && cleaned.length >= 8) {
+    return `+${cleaned}`
+  }
+
+  // If number starts with 00 (international format), replace with +
+  if (cleaned.startsWith('00') && cleaned.length >= 10) {
+    return `+${cleaned.substring(2)}`
+  }
+
+  // If number starts with country code (without +), add +
+  // Check for common country codes (1-3 digits)
+  if (cleaned.length >= 10) {
+    // Try to detect if it already starts with a country code
+    // Common patterns:
+    // - 36... (Hungary - 2 digits)
+    // - 1... (US/Canada - 1 digit)
+    // - 44... (UK - 2 digits)
+    // - etc.
+
+    // If starts with common country codes, add +
+    const twoDigitPrefix = cleaned.substring(0, 2)
+    const threeDigitPrefix = cleaned.substring(0, 3)
+
+    // List of common 2-digit country codes
+    const twoDigitCodes = ['36', '44', '49', '33', '34', '39', '48', '31', '32', '43', '41', '45', '46', '47', '90']
+    // List of common 3-digit country codes
+    const threeDigitCodes = ['420', '421']
+
+    if (threeDigitCodes.includes(threeDigitPrefix) && cleaned.length >= 12) {
+      return `+${cleaned}`
+    }
+
+    if (twoDigitCodes.includes(twoDigitPrefix)) {
+      return `+${cleaned}`
+    }
+
+    // If starts with 1 and is 11 digits (US/Canada)
+    if (cleaned.startsWith('1') && cleaned.length === 11) {
+      return `+${cleaned}`
+    }
+  }
+
+  // If number doesn't have country code, add default
+  // Remove leading 0 if present (common in local formats)
+  if (cleaned.startsWith('0')) {
+    cleaned = cleaned.substring(1)
+  }
+
+  // Add default country code
+  if (cleaned.length >= 8) {
+    return `+${defaultCountryCode}${cleaned}`
+  }
+
+  // Number too short to be valid, return null
+  return null
+}
+
+/**
+ * Format multiple phone numbers (useful for arrays or fallback chains)
+ * Returns the first valid formatted phone number
+ *
+ * @param phoneNumbers - Array of phone number strings to try
+ * @param defaultCountryCode - Default country code to use
+ * @returns First valid formatted phone number or null
+ */
+export function formatFirstValidPhone(
+  phoneNumbers: (string | null | undefined)[],
+  defaultCountryCode: string = '36'
+): string | null {
+  for (const phone of phoneNumbers) {
+    const formatted = formatPhoneNumber(phone, defaultCountryCode)
+    if (formatted) {
+      return formatted
+    }
+  }
+  return null
+}
+
+/**
+ * Detect country code from store domain or settings
+ * Used to set appropriate default country code per store
+ *
+ * @param storeDomain - Store domain or URL
+ * @returns Detected country code or '36' (Hungary) as default
+ */
+export function detectCountryCode(storeDomain: string | null | undefined): string {
+  if (!storeDomain) {
+    return '36' // Default to Hungary
+  }
+
+  const domain = storeDomain.toLowerCase()
+
+  // Country code mappings based on TLD or domain patterns
+  const countryMappings: Record<string, string> = {
+    '.hu': '36',  // Hungary
+    '.sk': '421', // Slovakia
+    '.cz': '420', // Czech Republic
+    '.ro': '40',  // Romania
+    '.at': '43',  // Austria
+    '.de': '49',  // Germany
+    '.uk': '44',  // United Kingdom
+    '.us': '1',   // United States
+    '.ca': '1',   // Canada
+    '.fr': '33',  // France
+    '.es': '34',  // Spain
+    '.it': '39',  // Italy
+    '.pl': '48',  // Poland
+    '.nl': '31',  // Netherlands
+    '.be': '32',  // Belgium
+    '.ch': '41',  // Switzerland
+    '.se': '46',  // Sweden
+    '.no': '47',  // Norway
+    '.dk': '45',  // Denmark
+    '.tr': '90',  // Turkey
+  }
+
+  // Check for TLD match
+  for (const [tld, code] of Object.entries(countryMappings)) {
+    if (domain.includes(tld)) {
+      return code
+    }
+  }
+
+  // Default to Hungary if no match
+  return '36'
+}

+ 21 - 7
supabase/functions/shopify-sync/index.ts

@@ -8,6 +8,7 @@ import {
   ShopifyOrder,
   ShopifyOrder,
   ShopifyCustomer
   ShopifyCustomer
 } from '../_shared/shopify-client.ts'
 } from '../_shared/shopify-client.ts'
+import { formatFirstValidPhone, detectCountryCode } from '../_shared/phone-formatter.ts'
 
 
 const corsHeaders = {
 const corsHeaders = {
   'Access-Control-Allow-Origin': '*',
   'Access-Control-Allow-Origin': '*',
@@ -135,7 +136,8 @@ async function syncProducts(
 // Sync orders from Shopify
 // Sync orders from Shopify
 async function syncOrders(
 async function syncOrders(
   storeId: string,
   storeId: string,
-  supabaseAdmin: any
+  supabaseAdmin: any,
+  countryCode: string
 ): Promise<{ synced: number; errors: number }> {
 ): Promise<{ synced: number; errors: number }> {
   console.log('[Shopify] Syncing orders...')
   console.log('[Shopify] Syncing orders...')
   let synced = 0
   let synced = 0
@@ -156,7 +158,11 @@ async function syncOrders(
       order_number: order.order_number.toString(),
       order_number: order.order_number.toString(),
       name: order.name,
       name: order.name,
       email: order.email || null,
       email: order.email || null,
-      phone: order.customer?.phone || order.billing_address?.phone || null,
+      phone: formatFirstValidPhone([
+        order.customer?.phone,
+        order.billing_address?.phone,
+        order.shipping_address?.phone
+      ], countryCode),
       financial_status: order.financial_status,
       financial_status: order.financial_status,
       fulfillment_status: order.fulfillment_status || null,
       fulfillment_status: order.fulfillment_status || null,
       total_price: parseFloat(order.current_total_price) || 0,
       total_price: parseFloat(order.current_total_price) || 0,
@@ -208,7 +214,8 @@ async function syncOrders(
 // Sync customers from Shopify
 // Sync customers from Shopify
 async function syncCustomers(
 async function syncCustomers(
   storeId: string,
   storeId: string,
-  supabaseAdmin: any
+  supabaseAdmin: any,
+  countryCode: string
 ): Promise<{ synced: number; errors: number }> {
 ): Promise<{ synced: number; errors: number }> {
   console.log('[Shopify] Syncing customers...')
   console.log('[Shopify] Syncing customers...')
   let synced = 0
   let synced = 0
@@ -229,7 +236,10 @@ async function syncCustomers(
       email: customer.email,
       email: customer.email,
       first_name: customer.first_name || null,
       first_name: customer.first_name || null,
       last_name: customer.last_name || null,
       last_name: customer.last_name || null,
-      phone: customer.phone || null,
+      phone: formatFirstValidPhone([
+        customer.phone,
+        customer.default_address?.phone
+      ], countryCode),
       accepts_marketing: customer.accepts_marketing || false,
       accepts_marketing: customer.accepts_marketing || false,
       orders_count: customer.orders_count || 0,
       orders_count: customer.orders_count || 0,
       total_spent: parseFloat(customer.total_spent) || 0,
       total_spent: parseFloat(customer.total_spent) || 0,
@@ -320,7 +330,7 @@ serve(async (req) => {
 
 
     const { data: store, error: storeError } = await supabaseAdmin
     const { data: store, error: storeError } = await supabaseAdmin
       .from('stores')
       .from('stores')
-      .select('id, platform_name, store_name')
+      .select('id, platform_name, store_name, store_url')
       .eq('id', storeId)
       .eq('id', storeId)
       .eq('user_id', user.id)
       .eq('user_id', user.id)
       .eq('platform_name', 'shopify')
       .eq('platform_name', 'shopify')
@@ -333,6 +343,10 @@ serve(async (req) => {
       )
       )
     }
     }
 
 
+    // Detect country code from store URL for phone formatting
+    const countryCode = detectCountryCode(store.store_url)
+    console.log(`[Shopify] Detected country code: ${countryCode} for store: ${store.store_name}`)
+
     console.log(`[Shopify] Starting ${syncType} sync for store: ${store.store_name}`)
     console.log(`[Shopify] Starting ${syncType} sync for store: ${store.store_name}`)
 
 
     const results: any = {
     const results: any = {
@@ -348,11 +362,11 @@ serve(async (req) => {
     }
     }
 
 
     if (syncType === 'all' || syncType === 'orders') {
     if (syncType === 'all' || syncType === 'orders') {
-      results.orders = await syncOrders(storeId, supabaseAdmin)
+      results.orders = await syncOrders(storeId, supabaseAdmin, countryCode)
     }
     }
 
 
     if (syncType === 'all' || syncType === 'customers') {
     if (syncType === 'all' || syncType === 'customers') {
-      results.customers = await syncCustomers(storeId, supabaseAdmin)
+      results.customers = await syncCustomers(storeId, supabaseAdmin, countryCode)
     }
     }
 
 
     results.completed_at = new Date().toISOString()
     results.completed_at = new Date().toISOString()

+ 16 - 2
supabase/functions/shoprenter-scheduled-sync/index.ts

@@ -1,6 +1,7 @@
 import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
 import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
 import { fetchProducts, fetchOrders, fetchCustomers } from '../_shared/shoprenter-client.ts'
 import { fetchProducts, fetchOrders, fetchCustomers } from '../_shared/shoprenter-client.ts'
+import { formatFirstValidPhone, detectCountryCode } from '../_shared/phone-formatter.ts'
 
 
 const corsHeaders = {
 const corsHeaders = {
   'Access-Control-Allow-Origin': '*',
   'Access-Control-Allow-Origin': '*',
@@ -81,6 +82,10 @@ serve(async (req) => {
       const storeId = store.id
       const storeId = store.id
       console.log(`[ShopRenter Scheduled Sync] Starting sync for store ${storeId} (${store.store_name})`)
       console.log(`[ShopRenter Scheduled Sync] Starting sync for store ${storeId} (${store.store_name})`)
 
 
+      // Detect country code from store URL for phone formatting
+      const countryCode = detectCountryCode(store.store_url)
+      console.log(`[ShopRenter Scheduled Sync] Detected country code: ${countryCode} for store: ${store.store_name}`)
+
       const syncStats = {
       const syncStats = {
         store_id: storeId,
         store_id: storeId,
         store_name: store.store_name,
         store_name: store.store_name,
@@ -173,7 +178,12 @@ serve(async (req) => {
                 currency: order.currency || 'HUF',
                 currency: order.currency || 'HUF',
                 customer_name: order.customer_name || `${order.customer?.firstname || ''} ${order.customer?.lastname || ''}`.trim() || null,
                 customer_name: order.customer_name || `${order.customer?.firstname || ''} ${order.customer?.lastname || ''}`.trim() || null,
                 customer_email: order.customer_email || order.customer?.email || null,
                 customer_email: order.customer_email || order.customer?.email || null,
-                customer_phone: order.customer_phone || order.customer?.phone || order.billing_address?.phone || order.shipping_address?.phone || null,
+                customer_phone: formatFirstValidPhone([
+                  order.customer_phone,
+                  order.customer?.phone,
+                  order.billing_address?.phone,
+                  order.shipping_address?.phone
+                ], countryCode),
                 line_items: order.items || order.line_items || [],
                 line_items: order.items || order.line_items || [],
                 billing_address: order.billing_address || null,
                 billing_address: order.billing_address || null,
                 shipping_address: order.shipping_address || null,
                 shipping_address: order.shipping_address || null,
@@ -233,7 +243,11 @@ serve(async (req) => {
                 email: customer.email,
                 email: customer.email,
                 first_name: customer.firstname,
                 first_name: customer.firstname,
                 last_name: customer.lastname,
                 last_name: customer.lastname,
-                phone: customer.phone || null,
+                phone: formatFirstValidPhone([
+                  customer.phone,
+                  customer.billing_address?.phone,
+                  customer.shipping_address?.phone
+                ], countryCode),
                 billing_address: customer.billing_address || null,
                 billing_address: customer.billing_address || null,
                 shipping_address: customer.shipping_address || null,
                 shipping_address: customer.shipping_address || null,
                 orders_count: customer.orders_count || 0,
                 orders_count: customer.orders_count || 0,

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

@@ -1,6 +1,7 @@
 import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
 import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
 import { fetchProducts, fetchOrders, fetchCustomers } from '../_shared/shoprenter-client.ts'
 import { fetchProducts, fetchOrders, fetchCustomers } from '../_shared/shoprenter-client.ts'
+import { formatFirstValidPhone, detectCountryCode } from '../_shared/phone-formatter.ts'
 
 
 const corsHeaders = {
 const corsHeaders = {
   'Access-Control-Allow-Origin': '*',
   'Access-Control-Allow-Origin': '*',
@@ -51,7 +52,7 @@ serve(async (req) => {
     // Verify store belongs to user
     // Verify store belongs to user
     const { data: store, error: storeError } = await supabase
     const { data: store, error: storeError } = await supabase
       .from('stores')
       .from('stores')
-      .select('id, store_name, platform_name')
+      .select('id, store_name, platform_name, store_url')
       .eq('id', storeId)
       .eq('id', storeId)
       .eq('user_id', user.id)
       .eq('user_id', user.id)
       .eq('platform_name', 'shoprenter')
       .eq('platform_name', 'shoprenter')
@@ -64,6 +65,10 @@ serve(async (req) => {
       )
       )
     }
     }
 
 
+    // Detect country code from store URL for phone formatting
+    const countryCode = detectCountryCode(store.store_url)
+    console.log(`[ShopRenter] Detected country code: ${countryCode} for store: ${store.store_name}`)
+
     console.log(`[ShopRenter] Starting full sync for store ${storeId}`)
     console.log(`[ShopRenter] Starting full sync for store ${storeId}`)
 
 
     const syncStats = {
     const syncStats = {
@@ -153,7 +158,12 @@ serve(async (req) => {
             currency: order.currency || 'HUF',
             currency: order.currency || 'HUF',
             customer_name: order.customer_name || `${order.customer?.firstname || ''} ${order.customer?.lastname || ''}`.trim() || null,
             customer_name: order.customer_name || `${order.customer?.firstname || ''} ${order.customer?.lastname || ''}`.trim() || null,
             customer_email: order.customer_email || order.customer?.email || null,
             customer_email: order.customer_email || order.customer?.email || null,
-            customer_phone: order.customer_phone || order.customer?.phone || order.billing_address?.phone || order.shipping_address?.phone || null,
+            customer_phone: formatFirstValidPhone([
+              order.customer_phone,
+              order.customer?.phone,
+              order.billing_address?.phone,
+              order.shipping_address?.phone
+            ], countryCode),
             line_items: order.items || order.line_items || [],
             line_items: order.items || order.line_items || [],
             billing_address: order.billing_address || null,
             billing_address: order.billing_address || null,
             shipping_address: order.shipping_address || null,
             shipping_address: order.shipping_address || null,
@@ -212,7 +222,11 @@ serve(async (req) => {
             email: customer.email,
             email: customer.email,
             first_name: customer.firstname,
             first_name: customer.firstname,
             last_name: customer.lastname,
             last_name: customer.lastname,
-            phone: customer.phone || null,
+            phone: formatFirstValidPhone([
+              customer.phone,
+              customer.billing_address?.phone,
+              customer.shipping_address?.phone
+            ], countryCode),
             billing_address: customer.billing_address || null,
             billing_address: customer.billing_address || null,
             shipping_address: customer.shipping_address || null,
             shipping_address: customer.shipping_address || null,
             orders_count: customer.orders_count || 0,
             orders_count: customer.orders_count || 0,

+ 19 - 6
supabase/functions/woocommerce-sync/index.ts

@@ -8,6 +8,7 @@ import {
   WooCommerceOrder,
   WooCommerceOrder,
   WooCommerceCustomer
   WooCommerceCustomer
 } from '../_shared/woocommerce-client.ts'
 } from '../_shared/woocommerce-client.ts'
+import { formatFirstValidPhone, detectCountryCode } from '../_shared/phone-formatter.ts'
 
 
 const corsHeaders = {
 const corsHeaders = {
   'Access-Control-Allow-Origin': '*',
   'Access-Control-Allow-Origin': '*',
@@ -150,7 +151,8 @@ async function syncProducts(
 async function syncOrders(
 async function syncOrders(
   storeId: string,
   storeId: string,
   supabaseAdmin: any,
   supabaseAdmin: any,
-  rateLimiter: RateLimiter
+  rateLimiter: RateLimiter,
+  countryCode: string
 ): Promise<{ synced: number; errors: number; errorMessage?: string }> {
 ): Promise<{ synced: number; errors: number; errorMessage?: string }> {
   console.log('[WooCommerce] Syncing orders...')
   console.log('[WooCommerce] Syncing orders...')
   let synced = 0
   let synced = 0
@@ -178,7 +180,10 @@ async function syncOrders(
         currency: order.currency || 'USD',
         currency: order.currency || 'USD',
         customer_name: `${order.billing?.first_name || ''} ${order.billing?.last_name || ''}`.trim(),
         customer_name: `${order.billing?.first_name || ''} ${order.billing?.last_name || ''}`.trim(),
         customer_email: order.billing?.email || null,
         customer_email: order.billing?.email || null,
-        customer_phone: order.billing?.phone || null,
+        customer_phone: formatFirstValidPhone([
+          order.billing?.phone,
+          order.shipping?.phone
+        ], countryCode),
         line_items: order.line_items || [],
         line_items: order.line_items || [],
         billing_address: order.billing || null,
         billing_address: order.billing || null,
         shipping_address: order.shipping || null,
         shipping_address: order.shipping || null,
@@ -224,7 +229,8 @@ async function syncOrders(
 async function syncCustomers(
 async function syncCustomers(
   storeId: string,
   storeId: string,
   supabaseAdmin: any,
   supabaseAdmin: any,
-  rateLimiter: RateLimiter
+  rateLimiter: RateLimiter,
+  countryCode: string
 ): Promise<{ synced: number; errors: number; errorMessage?: string }> {
 ): Promise<{ synced: number; errors: number; errorMessage?: string }> {
   console.log('[WooCommerce] Syncing customers...')
   console.log('[WooCommerce] Syncing customers...')
   let synced = 0
   let synced = 0
@@ -250,7 +256,10 @@ async function syncCustomers(
         first_name: customer.first_name || null,
         first_name: customer.first_name || null,
         last_name: customer.last_name || null,
         last_name: customer.last_name || null,
         username: customer.username || null,
         username: customer.username || null,
-        phone: customer.billing?.phone || null,
+        phone: formatFirstValidPhone([
+          customer.billing?.phone,
+          customer.shipping?.phone
+        ], countryCode),
         billing_address: customer.billing || null,
         billing_address: customer.billing || null,
         shipping_address: customer.shipping || null,
         shipping_address: customer.shipping || null,
         orders_count: customer.orders_count || 0,
         orders_count: customer.orders_count || 0,
@@ -452,6 +461,10 @@ serve(async (req) => {
         )
         )
       }
       }
 
 
+      // Detect country code from store URL for phone formatting
+      const countryCode = detectCountryCode(store.store_url)
+      console.log(`[WooCommerce] Detected country code: ${countryCode} for store: ${store.store_name}`)
+
       console.log(`[WooCommerce] Starting ${sync_type} sync for store ${store_id}`)
       console.log(`[WooCommerce] Starting ${sync_type} sync for store ${store_id}`)
 
 
       const syncStats: any = {
       const syncStats: any = {
@@ -468,11 +481,11 @@ serve(async (req) => {
       }
       }
 
 
       if (sync_type === 'orders' || sync_type === 'all') {
       if (sync_type === 'orders' || sync_type === 'all') {
-        syncStats.orders = await syncOrders(store_id, supabaseAdmin, rateLimiter)
+        syncStats.orders = await syncOrders(store_id, supabaseAdmin, rateLimiter, countryCode)
       }
       }
 
 
       if (sync_type === 'customers' || sync_type === 'all') {
       if (sync_type === 'customers' || sync_type === 'all') {
-        syncStats.customers = await syncCustomers(store_id, supabaseAdmin, rateLimiter)
+        syncStats.customers = await syncCustomers(store_id, supabaseAdmin, rateLimiter, countryCode)
       }
       }
 
 
       // Collect error messages for better diagnostics
       // Collect error messages for better diagnostics