One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
1233 lines
50 KiB
Python
1233 lines
50 KiB
Python
"""
|
|
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"
|
|
} |