""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import traceback from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse, HTMLResponse from sqlalchemy.orm import Session from app.core.config import settings from app.core.logger import get_logger from app.database import get_db from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShop, ShopifyShopUpdate from app.models.schemas.shopify import AgentShopifyConfigBase, AgentShopifyConfig, AgentShopifyConfigCreate, AgentShopifyConfigUpdate from app.repositories.shopify_shop_repository import ShopifyShopRepository from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository from typing import Optional import requests import hmac import hashlib import base64 import json from urllib.parse import urlencode, quote from app.core.auth import get_current_user, require_permissions, get_current_organization, check_permissions from app.models.user import User from app.models.organization import Organization from app.repositories.widget import WidgetRepository from app.services.shopify import ShopifyService from app.services.shopify_auth_service import ShopifyAuthService from app.services.shopify_helper_service import ShopifyHelperService from app.services.shopify_session import require_shopify_session from app.services.shopify_urls import shopify_admin_url, shopify_oauth_token_url from app.repositories.knowledge import KnowledgeRepository from app.repositories.knowledge_queue import KnowledgeQueueRepository from app.repositories.knowledge_to_agent import KnowledgeToAgentRepository from app.models.knowledge import SourceType from app.models.knowledge_queue import KnowledgeQueue, QueueStatus from app.models.knowledge_to_agent import KnowledgeToAgent from uuid import UUID from app.core.cors import update_cors_middleware from app.core.application import app from app.core.params import MAX_DB_INT router = APIRouter() logger = get_logger(__name__) # Define the scopes needed for your app SCOPES = "read_products,read_themes,write_themes,write_script_tags,read_script_tags,read_orders,read_all_orders,read_customers" def _require_shopify_agent_in_org(db, agent_id, org_id) -> None: """The agent being configured must belong to the resolved org, else 404. agent_id and org_id both arrive as either strings or UUIDs depending on the auth path; a malformed id is simply not found rather than a 500. """ from app.repositories.agent import AgentRepository try: agent_uuid = UUID(str(agent_id)) org_uuid = UUID(str(org_id)) except (ValueError, TypeError): raise HTTPException(status_code=404, detail="Agent not found") if not AgentRepository(db).get_agent_in_org(agent_uuid, org_uuid): raise HTTPException(status_code=404, detail="Agent not found") @router.post("/exchange-token") async def exchange_session_token( request: Request, db: Session = Depends(get_db) ): """ Exchange Shopify session token for offline access token via Shopify token exchange API. Called when app is first loaded in embedded context. Stores offline access token in database for future Shopify API calls. """ try: # 1. Get session token from Authorization header from app.services.shopify_session import ShopifySessionService session_token = ShopifySessionService.get_session_token_from_request(request) if not session_token: raise HTTPException(status_code=401, detail="Session token required") logger.info("Exchanging session token for offline access token") # 2. Verify the session token's signature before trusting any claim. This request # carries client_secret to the host named by `dest`, so an unverified token # would let a caller steer our credentials to a host of their choosing. decoded = ShopifySessionService.validate_session_token(session_token) shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '') # `dest` must be a Shopify shop domain, never a caller-chosen host. if not shop_domain or not shop_domain.split('/')[0].endswith('.myshopify.com'): raise HTTPException(status_code=400, detail="Invalid session token: bad shop domain") logger.info(f"Extracted shop domain from token: {shop_domain}") # 3. Call Shopify token exchange API token_url = shopify_oauth_token_url(shop_domain) payload = { "client_id": settings.SHOPIFY_API_KEY, "client_secret": settings.SHOPIFY_API_SECRET, "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token": session_token, "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", "requested_token_type": "urn:shopify:params:oauth:token-type:offline-access-token" } logger.info(f"Calling Shopify token exchange API for shop: {shop_domain}") response = requests.post(token_url, json=payload) if response.status_code != 200: logger.error(f"Token exchange failed: {response.status_code} - {response.text}") raise HTTPException( status_code=response.status_code, detail=f"Token exchange failed: {response.text}" ) token_data = response.json() access_token = token_data.get('access_token') if not access_token: logger.error(f"No access token in response: {token_data}") raise HTTPException(status_code=500, detail="No access token received from Shopify") logger.info(f"Successfully received offline access token for shop: {shop_domain}") # 4. Create or update shop with offline access token shop_repo = ShopifyShopRepository(db) db_shop = shop_repo.get_shop_by_domain(shop_domain) if not db_shop: logger.info(f"Creating new shop record for: {shop_domain}") shop_data = ShopifyShopCreate( shop_domain=shop_domain, access_token=access_token, is_installed=True ) db_shop = shop_repo.create_shop(shop_data) else: logger.info(f"Updating existing shop record for: {shop_domain}") shop_update = ShopifyShopUpdate(access_token=access_token, is_installed=True) db_shop = shop_repo.update_shop(db_shop.id, shop_update) db.commit() db.refresh(db_shop) logger.info(f"Shop record saved successfully. Shop ID: {db_shop.id}, Org ID: {db_shop.organization_id}") return { "shop_id": str(db_shop.id), "shop_domain": db_shop.shop_domain, "organization_id": str(db_shop.organization_id) if db_shop.organization_id else None, "is_installed": True } except HTTPException: raise except Exception as e: logger.error(f"Error exchanging session token: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Token exchange error: {str(e)}") @router.post("/link-organization") async def link_shop_to_org( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Link Shopify shop to authenticated user's organization after login. Called after user logs in via popup window. """ try: # Get shop_id from request body body = await request.json() shop_id = body.get('shop_id') if not shop_id: raise HTTPException(status_code=400, detail="shop_id is required") logger.info(f"Linking shop {shop_id} to user {current_user.id}'s organization") # Verify user has manage_organization permission if not check_permissions(current_user, ["manage_organization"]): raise HTTPException(status_code=403, detail="Insufficient permissions") if not current_user.organization_id: raise HTTPException(status_code=400, detail="User not associated with an organization") # Get shop from database shop_repo = ShopifyShopRepository(db) db_shop = shop_repo.get_shop(shop_id) if not db_shop: raise HTTPException(status_code=404, detail="Shop not found") org_id_str = str(current_user.organization_id) # Claiming is only for shops nobody owns yet — the same guard # /link-shop/{shop_id} has. Without it, any manage_organization user # could re-point another tenant's installed shop (and its stored # access token) at their own org, and drag that shop's domain onto # their organization record in the process. if db_shop.organization_id and str(db_shop.organization_id) != org_id_str: raise HTTPException( status_code=404, detail="Shop not found" ) # Update shop with organization_id shop_update = ShopifyShopUpdate(organization_id=org_id_str) shop_repo.update_shop(shop_id, shop_update) # Update organization domain with shop domain try: from app.repositories.organization import OrganizationRepository org_repo = OrganizationRepository(db) organization = org_repo.get_organization(org_id_str) if organization and db_shop.shop_domain: # Only update if domain is different if organization.domain != db_shop.shop_domain: # Check if domain is already used by another organization existing_org = org_repo.get_organization_by_domain(db_shop.shop_domain) if not existing_org or str(existing_org.id) == str(organization.id): # Update organization domain to shop domain organization.domain = db_shop.shop_domain db.add(organization) logger.info(f"Updated organization {org_id_str} domain to {db_shop.shop_domain}") else: logger.warning(f"Domain {db_shop.shop_domain} is already used by organization {existing_org.id}, skipping update") except Exception as domain_error: logger.error(f"Error updating organization domain: {str(domain_error)}") # Don't fail the linking if domain update fails db.commit() # Update CORS middleware to include new domain try: update_cors_middleware(app) logger.info("Updated CORS middleware after linking shop to organization") except Exception as cors_error: logger.error(f"Error updating CORS middleware: {str(cors_error)}") # Don't fail the linking if CORS update fails logger.info(f"Successfully linked shop {shop_id} to organization {org_id_str}") return { "success": True, "shop_id": shop_id, "organization_id": org_id_str } except HTTPException: raise except Exception as e: logger.error(f"Error linking shop to organization: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to link organization: {str(e)}") @router.get("/shops", response_model=list[ShopifyShop]) async def get_shops( db: Session = Depends(get_db), skip: int = Query(0, ge=0, le=MAX_DB_INT), limit: int = Query(100, ge=1, le=1000), organization: Organization = Depends(get_current_organization), current_user: User = Depends(require_permissions("manage_organization")) ): """ Get all shops for the current organization """ # Ensure organization_id is a string, not a UUID object org_id_str = str(organization.id) if organization and organization.id else None shop_repository = ShopifyShopRepository(db) shops = shop_repository.get_shops_by_organization(org_id_str, skip=skip, limit=limit) # Ensure organization_id is a string in each shop object for shop in shops: if shop.organization_id and not isinstance(shop.organization_id, str): shop.organization_id = str(shop.organization_id) return shops @router.get("/shops/{shop_id}", response_model=ShopifyShop) async def get_shop( shop_id: str, db: Session = Depends(get_db), organization: Organization = Depends(get_current_organization), current_user: User = Depends(require_permissions("manage_organization")) ): """ Get a shop by ID """ shop_repository = ShopifyShopRepository(db) db_shop = shop_repository.get_shop(shop_id) if not db_shop: raise HTTPException(status_code=404, detail="Shop not found") # Convert organization IDs to strings for comparison shop_org_id = str(db_shop.organization_id) if db_shop.organization_id else None current_org_id = str(organization.id) if organization.id else None # Ensure organization_id is a string, not a UUID object if db_shop.organization_id and not isinstance(db_shop.organization_id, str): db_shop.organization_id = str(db_shop.organization_id) # Verify that the shop belongs to the current organization if shop_org_id != current_org_id: logger.warning(f"Unauthorized attempt to access shop {shop_id} from organization {current_org_id}") raise HTTPException(status_code=403, detail="This shop does not belong to your organization") return db_shop @router.delete("/shops/{shop_id}") async def delete_shop( shop_id: str, db: Session = Depends(get_db), organization: Organization = Depends(get_current_organization), current_user: User = Depends(require_permissions("manage_organization")) ): """ Delete a shop and disconnect from Shopify """ shop_repository = ShopifyShopRepository(db) db_shop = shop_repository.get_shop(shop_id) if not db_shop: raise HTTPException(status_code=404, detail="Shop not found") # Convert organization IDs to strings for comparison shop_org_id = str(db_shop.organization_id) if db_shop.organization_id else None current_org_id = str(organization.id) if organization.id else None # Verify that the shop belongs to the current organization if shop_org_id != current_org_id: logger.warning(f"Unauthorized attempt to delete shop {shop_id} from organization {current_org_id}") raise HTTPException(status_code=403, detail="This shop does not belong to your organization") # Send an uninstall request to Shopify if we have an access token if db_shop.access_token: try: # Request to delete app data from Shopify uninstall_url = shopify_admin_url(db_shop.shop_domain, "graphql.json") headers = { "X-Shopify-Access-Token": db_shop.access_token, "Content-Type": "application/json" } # GraphQL mutation to uninstall the app mutation = """ mutation { appSubscriptionCancel( id: "gid://shopify/AppSubscription/current" ) { appSubscription { id status } userErrors { field message } } } """ response = requests.post( uninstall_url, headers=headers, json={"query": mutation} ) logger.info(f"Shopify uninstall response: {response.status_code} - {response.text}") except Exception as e: logger.error(f"Error uninstalling app from Shopify: {str(e)}") # Continue with deletion even if uninstall fails # Get all agent configs linked to this shop and delete them try: agent_config_repository = AgentShopifyConfigRepository(db) agent_configs = agent_config_repository.get_configs_by_shop(shop_id) if agent_configs: logger.info(f"Deleting {len(agent_configs)} agent Shopify configs for shop {shop_id}") for config in agent_configs: # Update the config to disable and remove shop_id agent_config_repository.update_agent_shopify_config( config.agent_id, AgentShopifyConfigUpdate(enabled=False, shop_id=None) ) logger.info(f"Successfully deleted agent Shopify configs for shop {shop_id}") except Exception as e: logger.error(f"Error deleting agent Shopify configs: {str(e)}") # Continue with shop deletion even if config deletion fails # Delete the shop from our database success = shop_repository.delete_shop(shop_id) if not success: raise HTTPException(status_code=500, detail="Failed to delete shop") return {"status": "success", "message": "Shop successfully disconnected"} @router.post("/link-shop/{shop_id}") async def link_shop_to_organization( shop_id: str, db: Session = Depends(get_db), organization: Organization = Depends(get_current_organization), current_user: User = Depends(require_permissions("manage_organization")) ): """ Link a Shopify shop to the current organization after user logs in. This is called after successful login from the embedded app flow. """ try: shop_repository = ShopifyShopRepository(db) db_shop = shop_repository.get_shop(shop_id) if not db_shop: raise HTTPException(status_code=404, detail="Shop not found") # Link shop to organization if not already linked if not db_shop.organization_id: org_id_str = str(organization.id) shop_update = ShopifyShopUpdate(organization_id=org_id_str) db_shop = shop_repository.update_shop(shop_id, shop_update) db.commit() logger.info(f"Linked shop {shop_id} to organization {org_id_str}") return { "success": True, "shop_id": str(db_shop.id), "shop_domain": db_shop.shop_domain, "organization_id": str(db_shop.organization_id) } except HTTPException: raise except Exception as e: logger.error(f"Error linking shop to organization: {str(e)}") raise HTTPException( status_code=500, detail="Error linking shop to organization" ) @router.get("/status") async def check_connection( db: Session = Depends(get_db), organization: Organization = Depends(get_current_organization), current_user: User = Depends(require_permissions("manage_organization")) ): """ Check if Shopify is connected for the current organization """ try: # Get shops for the current organization org_id_str = str(organization.id) if organization.id else None shop_repository = ShopifyShopRepository(db) shops = shop_repository.get_shops_by_organization(org_id_str, limit=1) is_connected = len(shops) > 0 and any(shop.is_installed for shop in shops) result = { "connected": is_connected } # If connected, include the shop domain if is_connected: result["shop_domain"] = shops[0].shop_domain return result except Exception as e: logger.error(f"Error checking Shopify connection: {str(e)}") raise HTTPException( status_code=500, detail="Error checking Shopify connection status" ) @router.get("/shop-config-status") async def get_shop_config_status( shopify_session: dict = Depends(require_shopify_session), db: Session = Depends(get_db) ): """ Check configuration status for a shop (agents configured, widget ID, etc.) Uses session token authentication for embedded apps. """ try: # Shop info already validated by session token db_shop = shopify_session['db_shop'] # Check if any agents are configured for this shop agent_config_repository = AgentShopifyConfigRepository(db) configs = agent_config_repository.get_configs_by_shop(str(db_shop.id), enabled_only=True) agents_connected = len(configs) if configs else 0 # Get widget ID if agents are configured widget_id = None if configs and len(configs) > 0: widget_repo = WidgetRepository(db) widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id) if widgets and len(widgets) > 0: widget_id = str(widgets[0].id) return { "shop_id": str(db_shop.id), "shop_domain": db_shop.shop_domain, "is_installed": db_shop.is_installed, "agents_connected": agents_connected, "widget_id": widget_id, "organization_id": str(db_shop.organization_id) if db_shop.organization_id else None } except HTTPException: raise except Exception as e: logger.error(f"Error getting shop config status: {str(e)}") raise HTTPException( status_code=500, detail="Error retrieving shop configuration status" ) @router.get("/organization-domain") async def get_organization_domain( shopify_session: dict = Depends(require_shopify_session), db: Session = Depends(get_db) ): """ Get organization domain from the organization table. Uses session token authentication for embedded apps. """ try: # Shop info already validated by session token db_shop = shopify_session['db_shop'] if not db_shop.organization_id: raise HTTPException(status_code=400, detail="Shop not linked to organization") # Get organization from app.repositories.organization import OrganizationRepository org_repo = OrganizationRepository(db) organization = org_repo.get_organization(str(db_shop.organization_id)) if not organization: raise HTTPException(status_code=404, detail="Organization not found") return { "organization_id": str(organization.id), "domain": organization.domain, "name": organization.name } except HTTPException: raise except Exception as e: logger.error(f"Error getting organization domain: {str(e)}") raise HTTPException( status_code=500, detail="Error retrieving organization domain" ) @router.put("/update-domain") async def update_organization_domain( request: Request, shopify_session: dict = Depends(require_shopify_session), db: Session = Depends(get_db) ): """ Update organization domain. Uses session token authentication for embedded apps. """ try: # Shop info already validated by session token db_shop = shopify_session['db_shop'] # Get domain from request body body = await request.json() new_domain = body.get('domain') if not new_domain: raise HTTPException(status_code=400, detail="domain is required") # Validate domain format (basic validation) new_domain = new_domain.strip() if not new_domain: raise HTTPException(status_code=400, detail="domain cannot be empty") # Get organization if not db_shop.organization_id: raise HTTPException(status_code=400, detail="Shop not linked to organization") from app.repositories.organization import OrganizationRepository org_repo = OrganizationRepository(db) organization = org_repo.get_organization(str(db_shop.organization_id)) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Update organization domain organization.domain = new_domain db.add(organization) db.commit() logger.info(f"Updated organization {organization.id} domain to {new_domain}") # Update CORS middleware to include new domain update_cors_middleware(app) logger.info("Updated CORS middleware after domain update") return { "success": True, "domain": new_domain, "organization_id": str(organization.id) } except HTTPException: raise except Exception as e: logger.error(f"Error updating organization domain: {str(e)}") # Check if it's a unique constraint violation if "duplicate key" in str(e).lower() or "unique constraint" in str(e).lower(): raise HTTPException( status_code=400, detail="Domain is already connected to another organization" ) raise HTTPException( status_code=500, detail="Error updating organization domain" ) @router.get("/connected-agents") async def get_connected_agents( shop_id: str = Query(..., description="Shop ID"), shopify_session: dict = Depends(require_shopify_session), db: Session = Depends(get_db) ): """ Get all agents connected to a Shopify shop. Uses session token authentication for embedded apps. """ try: # Verify shop_id matches the session if shop_id != shopify_session['shop_id']: raise HTTPException(status_code=403, detail="Shop ID mismatch") # Get agent configurations for this shop agent_config_repository = AgentShopifyConfigRepository(db) configs = agent_config_repository.get_configs_by_shop(shop_id, enabled_only=True) if not configs: return [] # Get full agent details for each config from app.repositories.agent import AgentRepository agent_repo = AgentRepository(db) agents = [] for config in configs: agent = agent_repo.get_agent(str(config.agent_id)) if agent: agents.append({ "id": str(agent.id), "name": agent.name, "display_name": agent.display_name, "description": agent.description, "is_active": agent.is_active, "organization_id": str(agent.organization_id) }) return agents except HTTPException: raise except Exception as e: logger.error(f"Error getting connected agents: {str(e)}") raise HTTPException( status_code=500, detail="Error retrieving connected agents" ) @router.get("/agent-config/{agent_id}", response_model=AgentShopifyConfig) async def get_agent_shopify_config( agent_id: str, request: Request, db: Session = Depends(get_db) ): """ Get Shopify configuration for an agent. Supports both session token auth (embedded) and JWT auth (dashboard). """ # Try session token first (for embedded apps) org_id = None try: shopify_session = await require_shopify_session(request, db) # Session token valid - verify agent belongs to shop's organization if not shopify_session['organization_id']: raise HTTPException(status_code=403, detail="Shop not linked to organization") org_id = shopify_session['organization_id'] logger.info(f"GET agent-config using session token for org: {org_id}") except HTTPException: # Fall back to JWT auth (for dashboard) current_user = await get_current_user(request=request, db=db) if not check_permissions(current_user, ["manage_organization"]): raise HTTPException(status_code=403, detail="Insufficient permissions") org_id = current_user.organization_id logger.info(f"GET agent-config using JWT auth for user: {current_user.id}") # The comment above always promised this — the agent must be in the # resolved org, else any tenant could read any agent's Shopify config. _require_shopify_agent_in_org(db, agent_id, org_id) agent_config_repository = AgentShopifyConfigRepository(db) config = agent_config_repository.get_agent_shopify_config(agent_id) if not config: # Return a default config if none exists raise HTTPException(status_code=404, detail="Shopify configuration not found for this agent") return config @router.post("/agent-config/{agent_id}", response_model=AgentShopifyConfig) async def save_agent_shopify_config( agent_id: str, config: AgentShopifyConfigBase, request: Request, db: Session = Depends(get_db) ): """ Save Shopify configuration for an agent. Automatically determines the shop ID for the organization. Supports both session token auth (embedded) and JWT auth (dashboard). """ agent_config_repository = AgentShopifyConfigRepository(db) shop_repository = ShopifyShopRepository(db) # Try session token first (for embedded apps) org_id_str = None user_id = None try: shopify_session = await require_shopify_session(request, db) # Session token valid - verify shop is linked to organization if not shopify_session['organization_id']: raise HTTPException(status_code=403, detail="Shop not linked to organization") # Ensure org_id_str is always a string org_id_str = str(shopify_session['organization_id']) # For session token, we don't have user_id - use None (optional for knowledge queue) logger.info(f"POST agent-config using session token for org: {org_id_str}") except HTTPException: # Fall back to JWT auth (for dashboard) current_user = await get_current_user(request=request, db=db) if not check_permissions(current_user, ["manage_organization"]): raise HTTPException(status_code=403, detail="Insufficient permissions") if not current_user.organization_id: raise HTTPException(status_code=403, detail="User not linked to organization") org_id_str = str(current_user.organization_id) user_id = current_user.id logger.info(f"POST agent-config using JWT auth for user: {current_user.id}") # The agent being configured must belong to the resolved org. _require_shopify_agent_in_org(db, agent_id, org_id_str) # Get the current config for the agent, if it exists existing_config = agent_config_repository.get_agent_shopify_config(agent_id) # Automatically determine the shop ID for this organization target_shop = None # Find the available installed shop for the organization shops = shop_repository.get_shops_by_organization(org_id_str, limit=10) installed_shops = [s for s in shops if s.is_installed] if installed_shops: target_shop = installed_shops[0] target_shop_id = target_shop.id else: target_shop_id = None if not installed_shops and config.enabled: raise HTTPException( status_code=400, detail="Cannot enable Shopify integration: No installed Shopify shops found for this organization." ) # --- Save Config to DB --- try: if existing_config: # Update existing config # Always use the automatically determined shop_id update_config = AgentShopifyConfigUpdate( enabled=config.enabled, shop_id=target_shop_id if config.enabled else None ) updated_config = agent_config_repository.update_agent_shopify_config( agent_id, update_config ) logger.info(f"Updated Shopify config for agent {agent_id}") saved_config = updated_config else: # Create new config # Always use the automatically determined shop_id config_create_data = AgentShopifyConfigCreate( agent_id=agent_id, enabled=config.enabled, shop_id=target_shop_id if config.enabled else None ) new_config = agent_config_repository.create_agent_shopify_config(config_create_data) logger.info(f"Created Shopify config for agent {agent_id}") saved_config = new_config # --- Link or Queue Knowledge Source --- if config.enabled and target_shop: try: org_uuid = UUID(org_id_str) agent_uuid = UUID(agent_id) shop_domain = target_shop.shop_domain if shop_domain: knowledge_repo = KnowledgeRepository(db) # Check if knowledge source already exists for this shop domain and org existing_knowledge_list = knowledge_repo.get_by_sources(org_uuid, [shop_domain]) existing_knowledge = existing_knowledge_list[0] if existing_knowledge_list else None if existing_knowledge is None: # Knowledge doesn't exist, queue it logger.info(f"Shop domain {shop_domain} not found in knowledge base for org {org_uuid}. Queuing...") queue_repo = KnowledgeQueueRepository(db) queue_item = KnowledgeQueue( organization_id=org_uuid, agent_id=agent_uuid, # Link to current agent user_id=user_id, # user_id from auth (JWT) or None (session token) source_type=SourceType.WEBSITE, source=shop_domain, status=QueueStatus.PENDING, queue_metadata={} ) queue_repo.create(queue_item) logger.info(f"Shop domain {shop_domain} queued for knowledge processing for agent {agent_id}.") else: # Knowledge exists, check if linked to agent logger.info(f"Shop domain {shop_domain} found in knowledge base (ID: {existing_knowledge.id}). Checking agent link...") link_repo = KnowledgeToAgentRepository(db) existing_link = link_repo.get_by_ids(existing_knowledge.id, agent_uuid) if existing_link is None: # Link doesn't exist, create it logger.info(f"Linking knowledge source {existing_knowledge.id} to agent {agent_id}...") link = KnowledgeToAgent(knowledge_id=existing_knowledge.id, agent_id=agent_uuid) link_repo.create(link) # Optional: Could add vector DB filter update here later if needed logger.info(f"Successfully linked knowledge source {existing_knowledge.id} to agent {agent_id}.") else: logger.info(f"Knowledge source {existing_knowledge.id} already linked to agent {agent_id}.") except Exception as ke: # Log the error but don't fail the main config saving operation logger.error(f"Failed to link or queue knowledge for shop {target_shop.id if target_shop else 'N/A'} / agent {agent_id}: {str(ke)}", exc_info=True) return saved_config # Return the saved/updated config except Exception as e: logger.error(f"Failed to save agent Shopify config to DB: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to save Shopify configuration to database.") @router.post("/webhooks/app-uninstalled") async def shopify_app_uninstalled_webhook( request: Request, db: Session = Depends(get_db) ): """ Webhook handler for Shopify app uninstallation. This endpoint is called by Shopify when a merchant uninstalls the app. It performs the following cleanup: 1. Marks the shop as uninstalled (is_installed = False) 2. Disables all agent Shopify configs for this shop 3. Optionally: Can clear access tokens for security Documentation: https://shopify.dev/docs/apps/build/webhooks/subscribe/get-started """ try: # Get the request body and headers request_body = await request.body() request_headers = request.headers # Verify the webhook signature if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): logger.warning("Invalid webhook signature for app/uninstalled") raise HTTPException(status_code=401, detail="Invalid webhook signature") # Parse the webhook payload try: payload = json.loads(request_body.decode('utf-8')) except json.JSONDecodeError as e: logger.error(f"Failed to parse webhook payload: {str(e)}") raise HTTPException(status_code=400, detail="Invalid JSON payload") # Extract shop domain from payload # Shopify sends the shop domain in the payload shop_domain = payload.get('domain') or payload.get('myshopify_domain') if not shop_domain: logger.error(f"No shop domain in webhook payload: {payload}") raise HTTPException(status_code=400, detail="Missing shop domain in payload") logger.info(f"Processing app uninstall webhook for shop: {shop_domain}") # Initialize repositories shop_repo = ShopifyShopRepository(db) config_repo = AgentShopifyConfigRepository(db) # Find the shop in our database db_shop = shop_repo.get_shop_by_domain(shop_domain) if not db_shop: logger.warning(f"Shop not found in database: {shop_domain}") # Return 200 anyway to acknowledge receipt return {"success": True, "message": "Shop not found, no action needed"} logger.info(f"Found shop in database: {db_shop.id}") # Delete all agent configurations for this shop configs = config_repo.get_configs_by_shop(str(db_shop.id)) deleted_configs = 0 for config in configs: config_repo.delete_agent_shopify_config(config.agent_id) deleted_configs += 1 logger.info(f"Deleted Shopify config for agent {config.agent_id}") logger.info(f"Deleted {deleted_configs} agent config(s) for shop {shop_domain}") # Delete the shop record from the database shop_repo.delete_shop(str(db_shop.id)) logger.info(f"Deleted shop record for {shop_domain}") # Commit the changes db.commit() logger.info(f"Successfully processed uninstall for {shop_domain}. Deleted shop and {deleted_configs} agent config(s)") # Return 200 OK to acknowledge receipt return { "success": True, "message": f"App uninstalled successfully for {shop_domain}", "shop_domain": shop_domain, "configs_deleted": deleted_configs } except HTTPException: raise except Exception as e: logger.error(f"Error processing app uninstall webhook: {str(e)}") logger.error(traceback.format_exc()) # Return 200 to prevent Shopify from retrying # Log the error for investigation return { "success": False, "message": "An internal error occurred while processing the uninstall webhook." } @router.post("/webhooks/customers/data_request") async def shopify_customers_data_request_webhook( request: Request, db: Session = Depends(get_db) ): """ GDPR Compliance Webhook: Customer Data Request This webhook is called when a customer requests access to their data. You must provide the customer's data within a specified timeframe. Shopify sends the following information: - shop_id: The ID of the shop - shop_domain: The shop's domain - customer: Customer information including email, phone - orders_requested: List of order IDs the customer has placed Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance """ try: # Get the request body and headers request_body = await request.body() request_headers = request.headers # Verify the webhook signature if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): logger.warning("Invalid webhook signature for customers/data_request") raise HTTPException(status_code=401, detail="Invalid webhook signature") # Parse the webhook payload try: payload = json.loads(request_body.decode('utf-8')) except json.JSONDecodeError as e: logger.error(f"Failed to parse webhook payload: {str(e)}") raise HTTPException(status_code=400, detail="Invalid JSON payload") shop_domain = payload.get('shop_domain') customer = payload.get('customer', {}) customer_email = customer.get('email') customer_phone = customer.get('phone') orders_requested = payload.get('orders_requested', []) logger.info(f"Received customer data request for shop: {shop_domain}, customer: {customer_email}") # TODO: Implement data collection based on your data storage # For now, we'll log the request and acknowledge receipt # You should: # 1. Collect all customer data you've stored (chat history, customer info, etc.) # 2. Prepare it in a readable format (JSON, CSV, etc.) # 3. Send it to the customer or make it available for download # 4. Keep a record of the request for compliance logger.info(f"Customer data request details:") logger.info(f" - Shop: {shop_domain}") logger.info(f" - Customer Email: {customer_email}") logger.info(f" - Customer Phone: {customer_phone}") logger.info(f" - Orders: {orders_requested}") # In a production system, you would: # 1. Query your chat_history table for this customer # 2. Query any customer-specific data # 3. Format and send the data to the customer # Return 200 OK to acknowledge receipt return { "success": True, "message": "Customer data request received and logged", "shop_domain": shop_domain, "customer_email": customer_email } except HTTPException: raise except Exception as e: logger.error(f"Error processing customer data request webhook: {str(e)}") logger.error(traceback.format_exc()) return { "success": False, "message": "Error processing webhook" } @router.post("/webhooks/customers/redact") async def shopify_customers_redact_webhook( request: Request, db: Session = Depends(get_db) ): """ GDPR Compliance Webhook: Customer Data Erasure This webhook is called when a customer requests their data to be deleted. You must delete all customer data within a specified timeframe. Shopify sends the following information: - shop_id: The ID of the shop - shop_domain: The shop's domain - customer: Customer information including email, phone - orders_to_redact: List of order IDs to redact Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance """ try: # Get the request body and headers request_body = await request.body() request_headers = request.headers # Verify the webhook signature if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): logger.warning("Invalid webhook signature for customers/redact") raise HTTPException(status_code=401, detail="Invalid webhook signature") # Parse the webhook payload try: payload = json.loads(request_body.decode('utf-8')) except json.JSONDecodeError as e: logger.error(f"Failed to parse webhook payload: {str(e)}") raise HTTPException(status_code=400, detail="Invalid JSON payload") shop_domain = payload.get('shop_domain') customer = payload.get('customer', {}) customer_email = customer.get('email') customer_phone = customer.get('phone') orders_to_redact = payload.get('orders_to_redact', []) logger.info(f"Received customer redact request for shop: {shop_domain}, customer: {customer_email}") # TODO: Implement data deletion based on your data storage # For now, we'll log the request and acknowledge receipt # You should: # 1. Delete or anonymize all customer data (chat history, personal info, etc.) # 2. Keep a minimal record for compliance (that the request was fulfilled) # 3. Ensure the customer's data is no longer accessible logger.info(f"Customer redact request details:") logger.info(f" - Shop: {shop_domain}") logger.info(f" - Customer Email: {customer_email}") logger.info(f" - Customer Phone: {customer_phone}") logger.info(f" - Orders to redact: {orders_to_redact}") # In a production system, you would: # 1. Find all chat_history records for this customer # 2. Either delete them or anonymize the customer data # 3. Remove any personally identifiable information (PII) # 4. Keep a log that the request was fulfilled # Example pseudocode: # chat_repo = ChatHistoryRepository(db) # if customer_email: # chat_repo.delete_by_customer_email(customer_email, shop_domain) # if customer_phone: # chat_repo.delete_by_customer_phone(customer_phone, shop_domain) # Return 200 OK to acknowledge receipt return { "success": True, "message": "Customer data redaction request received and logged", "shop_domain": shop_domain, "customer_email": customer_email } except HTTPException: raise except Exception as e: logger.error(f"Error processing customer redact webhook: {str(e)}") logger.error(traceback.format_exc()) return { "success": False, "message": "Error processing webhook" } @router.post("/webhooks/shop/redact") async def shopify_shop_redact_webhook( request: Request, db: Session = Depends(get_db) ): """ GDPR Compliance Webhook: Shop Data Erasure This webhook is called 48 hours after a shop owner uninstalls your app. You must delete all shop-related data. Shopify sends the following information: - shop_id: The ID of the shop - shop_domain: The shop's domain Documentation: https://shopify.dev/docs/apps/build/privacy-law-compliance """ try: # Get the request body and headers request_body = await request.body() request_headers = request.headers # Verify the webhook signature if not ShopifyHelperService.verify_shopify_webhook(request_headers, request_body): logger.warning("Invalid webhook signature for shop/redact") raise HTTPException(status_code=401, detail="Invalid webhook signature") # Parse the webhook payload try: payload = json.loads(request_body.decode('utf-8')) except json.JSONDecodeError as e: logger.error(f"Failed to parse webhook payload: {str(e)}") raise HTTPException(status_code=400, detail="Invalid JSON payload") shop_domain = payload.get('shop_domain') shop_id = payload.get('shop_id') logger.info(f"Received shop redact request for shop: {shop_domain} (ID: {shop_id})") # Initialize repositories shop_repo = ShopifyShopRepository(db) config_repo = AgentShopifyConfigRepository(db) # Find the shop in our database db_shop = shop_repo.get_shop_by_domain(shop_domain) if not db_shop: logger.warning(f"Shop not found in database: {shop_domain}") # Return 200 anyway to acknowledge receipt return {"success": True, "message": "Shop not found, no action needed"} logger.info(f"Found shop in database: {db_shop.id}") # Delete all agent configurations for this shop configs = config_repo.get_configs_by_shop(str(db_shop.id)) deleted_configs = 0 for config in configs: config_repo.delete_agent_shopify_config(config.agent_id) deleted_configs += 1 logger.info(f"Deleted Shopify config for agent {config.agent_id}") # TODO: Delete all shop-related data # You should also: # 1. Delete or anonymize all chat history related to this shop # 2. Remove any customer data associated with this shop # 3. Delete any cached product data # 4. Remove any analytics data for this shop # Example pseudocode: # chat_repo = ChatHistoryRepository(db) # chat_repo.delete_by_shop(shop_domain) # # customer_repo = CustomerRepository(db) # customer_repo.delete_by_shop(shop_domain) # Delete the shop record from the database shop_repo.delete_shop(str(db_shop.id)) logger.info(f"Deleted shop record for {shop_domain}") logger.info(f"Successfully redacted all data for shop {shop_domain}. Deleted {deleted_configs} config(s)") # Return 200 OK to acknowledge receipt return { "success": True, "message": f"Shop data redacted successfully for {shop_domain}", "shop_domain": shop_domain, "configs_deleted": deleted_configs } except HTTPException: raise except Exception as e: logger.error(f"Error processing shop redact webhook: {str(e)}") logger.error(traceback.format_exc()) return { "success": False, "message": "Error processing webhook" }