Browse Source

feat: add phone number integration to Shopify and ShopRenter OAuth flows #65

- Updated oauth-shopify to validate and store phone_number_id during OAuth
- Updated oauth-shoprenter-init and oauth-shoprenter-callback for phone number handling
- Added phone_number_id column to pending_shoprenter_installs table
- Updated stores/finalize-shoprenter endpoint to assign phone numbers atomically
- Added PhoneNumberSelector to ShopRenterConnect component
- All OAuth flows now require phone number selection before connection
- Phone numbers are validated for availability before assignment
- Rollback mechanism on phone assignment failure
Claude 5 months ago
parent
commit
a61f3a3e06

+ 24 - 3
shopcall.ai-main/src/components/ShopRenterConnect.tsx

@@ -6,6 +6,7 @@ import { Label } from "@/components/ui/label";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { Loader2, Store, ExternalLink, CheckCircle2 } from "lucide-react";
 import { Loader2, Store, ExternalLink, CheckCircle2 } from "lucide-react";
 import { API_URL } from "@/lib/config";
 import { API_URL } from "@/lib/config";
+import { PhoneNumberSelector } from "./PhoneNumberSelector";
 
 
 interface ShopRenterConnectProps {
 interface ShopRenterConnectProps {
   onClose?: () => void;
   onClose?: () => void;
@@ -13,6 +14,7 @@ interface ShopRenterConnectProps {
 
 
 export function ShopRenterConnect({ onClose }: ShopRenterConnectProps) {
 export function ShopRenterConnect({ onClose }: ShopRenterConnectProps) {
   const [shopUrl, setShopUrl] = useState("");
   const [shopUrl, setShopUrl] = useState("");
+  const [phoneNumberId, setPhoneNumberId] = useState<string>("");
   const [isConnecting, setIsConnecting] = useState(false);
   const [isConnecting, setIsConnecting] = useState(false);
   const [error, setError] = useState("");
   const [error, setError] = useState("");
   const [success, setSuccess] = useState(false);
   const [success, setSuccess] = useState(false);
@@ -27,6 +29,12 @@ export function ShopRenterConnect({ onClose }: ShopRenterConnectProps) {
       return;
       return;
     }
     }
 
 
+    // Validate phone number selection
+    if (!phoneNumberId) {
+      setError("Please select a phone number for this store");
+      return;
+    }
+
     // Normalize URL (remove protocol and trailing slash)
     // Normalize URL (remove protocol and trailing slash)
     let normalizedUrl = shopUrl.trim();
     let normalizedUrl = shopUrl.trim();
     normalizedUrl = normalizedUrl.replace(/^https?:\/\//, "");
     normalizedUrl = normalizedUrl.replace(/^https?:\/\//, "");
@@ -54,8 +62,8 @@ export function ShopRenterConnect({ onClose }: ShopRenterConnectProps) {
 
 
       const session = JSON.parse(sessionData);
       const session = JSON.parse(sessionData);
 
 
-      // Call the OAuth initiation Edge Function
-      const response = await fetch(`${API_URL}/oauth-shoprenter-init?shopname=${shopname}`, {
+      // Call the OAuth initiation Edge Function with phoneNumberId
+      const response = await fetch(`${API_URL}/oauth-shoprenter-init?shopname=${shopname}&phoneNumberId=${phoneNumberId}`, {
         method: 'GET',
         method: 'GET',
         headers: {
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Authorization': `Bearer ${session.session.access_token}`,
@@ -147,9 +155,22 @@ export function ShopRenterConnect({ onClose }: ShopRenterConnectProps) {
             </p>
             </p>
           </div>
           </div>
 
 
+          {/* Phone Number Selection */}
+          <div className="space-y-2">
+            <Label className="text-white">Select Phone Number *</Label>
+            <PhoneNumberSelector
+              value={phoneNumberId}
+              onChange={setPhoneNumberId}
+              disabled={isConnecting}
+            />
+            <p className="text-sm text-slate-400">
+              This phone number will be permanently assigned to your store
+            </p>
+          </div>
+
           <Button
           <Button
             onClick={handleConnect}
             onClick={handleConnect}
-            disabled={isConnecting}
+            disabled={isConnecting || !phoneNumberId}
             className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
             className="w-full bg-cyan-500 hover:bg-cyan-600 text-white"
           >
           >
             {isConnecting ? (
             {isConnecting ? (

+ 49 - 2
supabase/functions/api/index.ts

@@ -127,7 +127,32 @@ serve(async (req) => {
         )
         )
       }
       }
 
 
-      // Create store record
+      // Verify phone number
+      const phoneNumberId = installation.phone_number_id
+      if (!phoneNumberId) {
+        await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
+        return new Response(
+          JSON.stringify({ error: 'Phone number selection missing. Please reconnect your store.' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Verify phone number is still available
+      const { data: phoneNumber, error: phoneError } = await supabaseAdmin
+        .from('phone_numbers')
+        .select('id, is_available')
+        .eq('id', phoneNumberId)
+        .single()
+
+      if (phoneError || !phoneNumber || !phoneNumber.is_available) {
+        await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
+        return new Response(
+          JSON.stringify({ error: 'Selected phone number is no longer available. Please reconnect your store.' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Create store record with phone_number_id
       const { data: newStore, error: storeError } = await supabaseAdmin
       const { data: newStore, error: storeError } = await supabaseAdmin
         .from('stores')
         .from('stores')
         .insert({
         .insert({
@@ -138,6 +163,7 @@ serve(async (req) => {
           api_key: installation.access_token,
           api_key: installation.access_token,
           api_secret: installation.refresh_token,
           api_secret: installation.refresh_token,
           scopes: installation.scopes || [],
           scopes: installation.scopes || [],
+          phone_number_id: phoneNumberId,
           data_access_permissions: {
           data_access_permissions: {
             allow_customer_access: true,
             allow_customer_access: true,
             allow_order_access: true,
             allow_order_access: true,
@@ -160,10 +186,31 @@ serve(async (req) => {
         )
         )
       }
       }
 
 
+      // Update phone number to mark as assigned
+      const { error: phoneUpdateError } = await supabaseAdmin
+        .from('phone_numbers')
+        .update({
+          is_available: false,
+          store_id: newStore.id,
+          updated_at: new Date().toISOString()
+        })
+        .eq('id', phoneNumberId)
+
+      if (phoneUpdateError) {
+        console.error('[API] Error updating phone number:', phoneUpdateError)
+        // Rollback: delete the store
+        await supabaseAdmin.from('stores').delete().eq('id', newStore.id)
+        await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
+        return new Response(
+          JSON.stringify({ error: 'Phone number assignment failed. Please try again.' }),
+          { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
       // Delete pending installation
       // Delete pending installation
       await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
       await supabaseAdmin.from('pending_shoprenter_installs').delete().eq('installation_id', installation_id)
 
 
-      console.log(`[API] ShopRenter store finalized: ${installation.shopname}`)
+      console.log(`[API] ShopRenter store finalized: ${installation.shopname} with phone number ${phoneNumberId}`)
 
 
       // Trigger auto-sync in background
       // Trigger auto-sync in background
       const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
       const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`

+ 96 - 6
supabase/functions/oauth-shopify/index.ts

@@ -165,6 +165,7 @@ serve(wrapHandler('oauth-shopify', async (req) => {
     // ========================================================================
     // ========================================================================
     if (action === 'init') {
     if (action === 'init') {
       const shop = url.searchParams.get('shop')
       const shop = url.searchParams.get('shop')
+      const phoneNumberId = url.searchParams.get('phoneNumberId')
 
 
       if (!shop) {
       if (!shop) {
         return new Response(
         return new Response(
@@ -173,6 +174,13 @@ serve(wrapHandler('oauth-shopify', async (req) => {
         )
         )
       }
       }
 
 
+      if (!phoneNumberId) {
+        return new Response(
+          JSON.stringify({ error: 'phoneNumberId parameter is required' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
       // Validate shop domain
       // Validate shop domain
       const validation = validateShopDomain(shop)
       const validation = validateShopDomain(shop)
       if (!validation.valid) {
       if (!validation.valid) {
@@ -202,10 +210,33 @@ serve(wrapHandler('oauth-shopify', async (req) => {
         )
         )
       }
       }
 
 
+      // Verify phone number is available and belongs to user
+      const { data: phoneNumber, error: phoneError } = await supabase
+        .from('phone_numbers')
+        .select('id, is_available')
+        .eq('id', phoneNumberId)
+        .single()
+
+      if (phoneError || !phoneNumber) {
+        console.error('[Shopify] Phone number not found:', phoneError)
+        return new Response(
+          JSON.stringify({ error: 'Invalid phone number selected' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      if (!phoneNumber.is_available) {
+        console.error('[Shopify] Phone number not available:', phoneNumberId)
+        return new Response(
+          JSON.stringify({ error: 'Selected phone number is no longer available' }),
+          { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
       // Generate state parameter for CSRF protection
       // Generate state parameter for CSRF protection
       const state = crypto.randomUUID()
       const state = crypto.randomUUID()
 
 
-      // Store state in database
+      // Store state in database with phone_number_id
       const { error: stateError } = await supabase
       const { error: stateError } = await supabase
         .from('oauth_states')
         .from('oauth_states')
         .insert({
         .insert({
@@ -213,6 +244,7 @@ serve(wrapHandler('oauth-shopify', async (req) => {
           user_id: user.id,
           user_id: user.id,
           platform: 'shopify',
           platform: 'shopify',
           shopname: validation.normalized,
           shopname: validation.normalized,
+          phone_number_id: phoneNumberId,
           expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
           expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
         })
         })
 
 
@@ -377,7 +409,39 @@ serve(wrapHandler('oauth-shopify', async (req) => {
       const storeName = shopData?.name || validation.normalized?.split('.')[0] || 'Unknown Store'
       const storeName = shopData?.name || validation.normalized?.split('.')[0] || 'Unknown Store'
       const shopDomain = validation.normalized!
       const shopDomain = validation.normalized!
 
 
-      // Store credentials in database
+      // Verify phone number is still available
+      const phoneNumberId = stateData.phone_number_id
+      if (!phoneNumberId) {
+        console.error('[Shopify] No phone number ID in state')
+        await supabaseAdmin.from('oauth_states').delete().eq('state', state)
+        return new Response(null, {
+          status: 302,
+          headers: {
+            ...corsHeaders,
+            'Location': `${frontendUrl}/webshops?error=phone_number_missing`
+          }
+        })
+      }
+
+      const { data: phoneNumber, error: phoneError } = await supabaseAdmin
+        .from('phone_numbers')
+        .select('id, is_available')
+        .eq('id', phoneNumberId)
+        .single()
+
+      if (phoneError || !phoneNumber || !phoneNumber.is_available) {
+        console.error('[Shopify] Phone number no longer available:', phoneNumberId)
+        await supabaseAdmin.from('oauth_states').delete().eq('state', state)
+        return new Response(null, {
+          status: 302,
+          headers: {
+            ...corsHeaders,
+            'Location': `${frontendUrl}/webshops?error=phone_number_unavailable`
+          }
+        })
+      }
+
+      // Store credentials in database with phone_number_id
       const { data: insertedStore, error: insertError } = await supabaseAdmin
       const { data: insertedStore, error: insertError } = await supabaseAdmin
         .from('stores')
         .from('stores')
         .insert({
         .insert({
@@ -387,6 +451,7 @@ serve(wrapHandler('oauth-shopify', async (req) => {
           store_url: shopDomain,
           store_url: shopDomain,
           api_key: tokenResult.accessToken,
           api_key: tokenResult.accessToken,
           scopes: tokenResult.scopes || SHOPIFY_SCOPES,
           scopes: tokenResult.scopes || SHOPIFY_SCOPES,
+          phone_number_id: phoneNumberId,
           data_access_permissions: {
           data_access_permissions: {
             allow_customer_access: true,
             allow_customer_access: true,
             allow_order_access: true,
             allow_order_access: true,
@@ -405,11 +470,9 @@ serve(wrapHandler('oauth-shopify', async (req) => {
         .select('id')
         .select('id')
         .single()
         .single()
 
 
-      // Clean up state
-      await supabaseAdmin.from('oauth_states').delete().eq('state', state)
-
       if (insertError || !insertedStore) {
       if (insertError || !insertedStore) {
         console.error('[Shopify] Error storing credentials:', insertError)
         console.error('[Shopify] Error storing credentials:', insertError)
+        await supabaseAdmin.from('oauth_states').delete().eq('state', state)
         return new Response(null, {
         return new Response(null, {
           status: 302,
           status: 302,
           headers: {
           headers: {
@@ -419,7 +482,34 @@ serve(wrapHandler('oauth-shopify', async (req) => {
         })
         })
       }
       }
 
 
-      console.log(`[Shopify] Store connected successfully: ${storeName} (${shopDomain})`)
+      // Update phone number to mark as assigned (is_available=false, store_id)
+      const { error: phoneUpdateError } = await supabaseAdmin
+        .from('phone_numbers')
+        .update({
+          is_available: false,
+          store_id: insertedStore.id,
+          updated_at: new Date().toISOString()
+        })
+        .eq('id', phoneNumberId)
+
+      if (phoneUpdateError) {
+        console.error('[Shopify] Error updating phone number:', phoneUpdateError)
+        // Rollback: delete the store
+        await supabaseAdmin.from('stores').delete().eq('id', insertedStore.id)
+        await supabaseAdmin.from('oauth_states').delete().eq('state', state)
+        return new Response(null, {
+          status: 302,
+          headers: {
+            ...corsHeaders,
+            'Location': `${frontendUrl}/webshops?error=phone_assignment_failed`
+          }
+        })
+      }
+
+      // Clean up state
+      await supabaseAdmin.from('oauth_states').delete().eq('state', state)
+
+      console.log(`[Shopify] Store connected successfully: ${storeName} (${shopDomain}) with phone number ${phoneNumberId}`)
 
 
       // Trigger auto-sync in background
       // Trigger auto-sync in background
       const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`
       const triggerSyncUrl = `${supabaseUrl}/functions/v1/trigger-sync`

+ 46 - 1
supabase/functions/oauth-shoprenter-callback/index.ts

@@ -171,7 +171,46 @@ serve(wrapHandler('oauth-shoprenter-callback', async (req) => {
     // Create Supabase client with service role key for admin operations
     // Create Supabase client with service role key for admin operations
     const supabase = createClient(supabaseUrl, supabaseServiceKey)
     const supabase = createClient(supabaseUrl, supabaseServiceKey)
 
 
-    // Store installation in pending state
+    // Retrieve oauth_state to get phone_number_id and user_id
+    const { data: oauthState, error: stateError } = await supabase
+      .from('oauth_states')
+      .select('*')
+      .eq('platform', 'shoprenter')
+      .eq('shopname', shopname)
+      .order('created_at', { ascending: false })
+      .limit(1)
+      .maybeSingle()
+
+    if (stateError) {
+      console.error('[ShopRenter] Error retrieving oauth state:', stateError)
+    }
+
+    const phoneNumberId = oauthState?.phone_number_id
+
+    if (!phoneNumberId) {
+      console.error('[ShopRenter] No phone number ID found in oauth state')
+      return new Response(
+        JSON.stringify({ error: 'Phone number selection missing. Please try connecting again.' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Verify phone number is still available
+    const { data: phoneNumber, error: phoneError } = await supabase
+      .from('phone_numbers')
+      .select('id, is_available')
+      .eq('id', phoneNumberId)
+      .single()
+
+    if (phoneError || !phoneNumber || !phoneNumber.is_available) {
+      console.error('[ShopRenter] Phone number no longer available:', phoneNumberId)
+      return new Response(
+        JSON.stringify({ error: 'Selected phone number is no longer available. Please try again.' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Store installation in pending state with phone_number_id
     const installationId = crypto.randomUUID()
     const installationId = crypto.randomUUID()
     const { error: installError } = await supabase
     const { error: installError } = await supabase
       .from('pending_shoprenter_installs')
       .from('pending_shoprenter_installs')
@@ -183,6 +222,7 @@ serve(wrapHandler('oauth-shoprenter-callback', async (req) => {
         token_type: tokenData.token_type || 'Bearer',
         token_type: tokenData.token_type || 'Bearer',
         expires_in: tokenData.expires_in || 3600,
         expires_in: tokenData.expires_in || 3600,
         scopes: tokenData.scope ? tokenData.scope.split(' ') : [],
         scopes: tokenData.scope ? tokenData.scope.split(' ') : [],
+        phone_number_id: phoneNumberId,
         expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString()
         expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString()
       })
       })
 
 
@@ -194,6 +234,11 @@ serve(wrapHandler('oauth-shoprenter-callback', async (req) => {
       )
       )
     }
     }
 
 
+    // Clean up oauth_state if found
+    if (oauthState) {
+      await supabase.from('oauth_states').delete().eq('id', oauthState.id)
+    }
+
     // Redirect to app_url or frontend with installation_id
     // Redirect to app_url or frontend with installation_id
     const redirectUrl = app_url
     const redirectUrl = app_url
       ? `${app_url}?sr_install=${installationId}`
       ? `${app_url}?sr_install=${installationId}`

+ 33 - 1
supabase/functions/oauth-shoprenter-init/index.ts

@@ -13,6 +13,7 @@ serve(wrapHandler('oauth-shoprenter-init', async (req) => {
   }
   }
     const url = new URL(req.url)
     const url = new URL(req.url)
     const shopname = url.searchParams.get('shopname')
     const shopname = url.searchParams.get('shopname')
+    const phoneNumberId = url.searchParams.get('phoneNumberId')
 
 
     if (!shopname) {
     if (!shopname) {
       return new Response(
       return new Response(
@@ -21,6 +22,13 @@ serve(wrapHandler('oauth-shoprenter-init', async (req) => {
       )
       )
     }
     }
 
 
+    if (!phoneNumberId) {
+      return new Response(
+        JSON.stringify({ error: 'phoneNumberId parameter is required' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
     // Get environment variables
     // Get environment variables
     const shoprenterClientId = Deno.env.get('SHOPRENTER_CLIENT_ID')
     const shoprenterClientId = Deno.env.get('SHOPRENTER_CLIENT_ID')
     const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://shopcall.ai'
     const frontendUrl = Deno.env.get('FRONTEND_URL') || 'https://shopcall.ai'
@@ -59,7 +67,30 @@ serve(wrapHandler('oauth-shoprenter-init', async (req) => {
       )
       )
     }
     }
 
 
-    // Store state in database with user_id for later validation
+    // Verify phone number is available
+    const { data: phoneNumber, error: phoneError } = await supabase
+      .from('phone_numbers')
+      .select('id, is_available')
+      .eq('id', phoneNumberId)
+      .single()
+
+    if (phoneError || !phoneNumber) {
+      console.error('[ShopRenter] Phone number not found:', phoneError)
+      return new Response(
+        JSON.stringify({ error: 'Invalid phone number selected' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    if (!phoneNumber.is_available) {
+      console.error('[ShopRenter] Phone number not available:', phoneNumberId)
+      return new Response(
+        JSON.stringify({ error: 'Selected phone number is no longer available' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
+    // Store state in database with user_id and phone_number_id for later validation
     const { error: stateError } = await supabase
     const { error: stateError } = await supabase
       .from('oauth_states')
       .from('oauth_states')
       .insert({
       .insert({
@@ -67,6 +98,7 @@ serve(wrapHandler('oauth-shoprenter-init', async (req) => {
         user_id: user.id,
         user_id: user.id,
         platform: 'shoprenter',
         platform: 'shoprenter',
         shopname,
         shopname,
+        phone_number_id: phoneNumberId,
         expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
         expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString() // 15 minutes
       })
       })