package service import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" "github.com/golang-jwt/jwt/v5" ) type ShopifyProviderError struct { Message string } func (e *ShopifyProviderError) Error() string { return e.Message } // ShopifyIntegrationService implements Shopify integration business logic. // Reference: Chatwoot Integrations::ShopifyController // Shopify integration links customer order information to conversations. type ShopifyIntegrationService struct { hookRepo *repository.IntegrationHookRepo contactRepo *repository.ContactRepo client *shopifyAPIClient } // NewShopifyIntegrationService creates a new ShopifyIntegrationService. func NewShopifyIntegrationService(hookRepo *repository.IntegrationHookRepo) *ShopifyIntegrationService { svc := &ShopifyIntegrationService{hookRepo: hookRepo, client: newShopifyAPIClientFromEnv()} if hookRepo != nil && hookRepo.DB() != nil { svc.contactRepo = repository.NewContactRepo(hookRepo.DB()) } return svc } // CreateShopifyAuthRequest is the DTO for Shopify OAuth auth. type CreateShopifyAuthRequest struct { ShopDomain string `json:"shop_domain" form:"shop_domain"` AccessToken string `json:"access_token,omitempty" form:"access_token"` } type ShopifyAuthResponse struct { RedirectURL string `json:"redirect_url"` } // Delete removes a Shopify integration hook for an account. func (s *ShopifyIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.findShopifyHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return fmt.Errorf("Shopify integration not found for account %d", accountID) } for _, hook := range hooks { if err := s.hookRepo.Delete(ctx, hook.ID); err != nil { return fmt.Errorf("failed to delete Shopify integration: %w", err) } } applogger.L().Infof("Shopify integration deleted: account=%d", accountID) return nil } // Auth creates/updates a Shopify integration with OAuth credentials. // POST /api/v1/accounts/:account_id/integrations/shopify/auth func (s *ShopifyIntegrationService) Auth(ctx context.Context, accountID uint, req CreateShopifyAuthRequest) (*model.IntegrationHook, error) { settings := model.ShopifySettings{ ShopDomain: req.ShopDomain, AccessToken: req.AccessToken, } settingsJSON, err := json.Marshal(settings) if err != nil { return nil, fmt.Errorf("failed to marshal Shopify settings: %w", err) } // Check if Shopify hook already exists for this account hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify) if err != nil { return nil, fmt.Errorf("failed to find existing Shopify integration: %w", err) } if len(hooks) > 0 { // Update existing hook hook := &hooks[0] hook.AppID = "shopify" hook.ReferenceID = req.ShopDomain hook.AccessToken = req.AccessToken hook.Settings = settingsJSON hook.URL = fmt.Sprintf("https://%s/admin/api/webhooks.json", req.ShopDomain) if err := s.hookRepo.Update(ctx, hook); err != nil { return nil, fmt.Errorf("failed to update Shopify integration: %w", err) } applogger.L().Infof("Shopify integration updated: account=%d, shop=%s", accountID, req.ShopDomain) return hook, nil } // Create new hook hook := &model.IntegrationHook{ AccountID: accountID, AppID: "shopify", HookType: model.HookTypeShopify, Status: model.HookStatusActive, URL: fmt.Sprintf("https://%s/admin/api/webhooks.json", req.ShopDomain), AccessToken: req.AccessToken, ReferenceID: req.ShopDomain, Settings: settingsJSON, } if err := s.hookRepo.Create(ctx, hook); err != nil { return nil, fmt.Errorf("failed to create Shopify integration: %w", err) } applogger.L().Infof("Shopify integration created: account=%d, shop=%s", accountID, req.ShopDomain) return hook, nil } // BuildAuthRedirect returns the Chatwoot-compatible Shopify OAuth authorize URL. // Reference: Api::V1::Accounts::Integrations::ShopifyController#auth. func (s *ShopifyIntegrationService) BuildAuthRedirect(_ context.Context, accountID uint, req CreateShopifyAuthRequest) (*ShopifyAuthResponse, error) { shopDomain := strings.TrimSpace(req.ShopDomain) if shopDomain == "" { return nil, fmt.Errorf("Shop domain is required") } clientID := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_ID")) clientSecret := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_SECRET")) if clientID == "" || clientSecret == "" { return nil, fmt.Errorf("Shopify OAuth is not configured") } token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": accountID, "iat": time.Now().Unix(), }) state, err := token.SignedString([]byte(clientSecret)) if err != nil { return nil, fmt.Errorf("failed to generate Shopify state: %w", err) } frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") if frontendURL == "" { frontendURL = "http://localhost:3000" } params := url.Values{} params.Set("client_id", clientID) params.Set("scope", "read_customers,read_orders,read_fulfillments") params.Set("redirect_uri", frontendURL+"/shopify/callback") params.Set("state", state) return &ShopifyAuthResponse{RedirectURL: fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shopDomain, params.Encode())}, nil } // GetOrders retrieves Shopify orders for an account (proxy to Shopify API). // GET /api/v1/accounts/:account_id/integrations/shopify/orders func (s *ShopifyIntegrationService) GetOrders(ctx context.Context, accountID, contactID uint) ([]map[string]interface{}, error) { hooks, err := s.findShopifyHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return nil, fmt.Errorf("Shopify integration not found for account %d", accountID) } if s.contactRepo == nil { return nil, fmt.Errorf("contact repository not configured") } contact, err := s.contactRepo.FindByAccountAndID(ctx, accountID, contactID) if err != nil || (strings.TrimSpace(contact.Email) == "" && strings.TrimSpace(contact.PhoneNumber) == "") { return nil, &ShopifyProviderError{Message: "Contact information missing"} } hook := hooks[0] shopDomain := strings.TrimSpace(hook.ReferenceID) if shopDomain == "" { var settings model.ShopifySettings if err := json.Unmarshal(hook.Settings, &settings); err != nil { return nil, fmt.Errorf("failed to unmarshal Shopify settings: %w", err) } shopDomain = settings.ShopDomain if hook.AccessToken == "" { hook.AccessToken = settings.AccessToken } } customers, err := s.client.SearchCustomers(ctx, shopDomain, hook.AccessToken, contact.Email, contact.PhoneNumber) if err != nil { return nil, err } if len(customers) == 0 { return []map[string]interface{}{}, nil } orders, err := s.client.GetOrders(ctx, shopDomain, hook.AccessToken, fmt.Sprint(customers[0]["id"])) if err != nil { return nil, err } for _, order := range orders { order["admin_url"] = fmt.Sprintf("https://%s/admin/orders/%v", shopDomain, order["id"]) } applogger.L().Infof("Listing Shopify orders for account=%d, shop=%s", accountID, shopDomain) return orders, nil } func (s *ShopifyIntegrationService) findShopifyHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "shopify") if err != nil { return nil, err } if len(hooks) > 0 { return hooks, nil } return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify) } type shopifyAPIClient struct { apiVersion string httpClient *http.Client } func newShopifyAPIClientFromEnv() *shopifyAPIClient { apiVersion := strings.TrimSpace(os.Getenv("SHOPIFY_API_VERSION")) if apiVersion == "" { apiVersion = "2025-01" } return &shopifyAPIClient{apiVersion: apiVersion, httpClient: &http.Client{Timeout: 15 * time.Second}} } func (c *shopifyAPIClient) SearchCustomers(ctx context.Context, shopDomain, token, email, phone string) ([]map[string]interface{}, error) { queryParts := []string{} if strings.TrimSpace(email) != "" { queryParts = append(queryParts, "email:"+strings.TrimSpace(email)) } if strings.TrimSpace(phone) != "" { queryParts = append(queryParts, "phone:"+strings.TrimSpace(phone)) } query := url.Values{} query.Set("query", strings.Join(queryParts, " OR ")) query.Set("fields", "id,email,phone") payload, err := c.get(ctx, shopDomain, token, "customers/search.json", query) if err != nil { return nil, err } return shopifyMapSlice(payload, "customers"), nil } func (c *shopifyAPIClient) GetOrders(ctx context.Context, shopDomain, token, customerID string) ([]map[string]interface{}, error) { query := url.Values{} query.Set("customer_id", customerID) query.Set("status", "any") query.Set("fields", "id,email,created_at,total_price,currency,fulfillment_status,financial_status") payload, err := c.get(ctx, shopDomain, token, "orders.json", query) if err != nil { return nil, err } return shopifyMapSlice(payload, "orders"), nil } func (c *shopifyAPIClient) get(ctx context.Context, shopDomain, token, path string, query url.Values) (map[string]interface{}, error) { shopDomain = strings.TrimSpace(shopDomain) if shopDomain == "" || strings.TrimSpace(token) == "" { return nil, &ShopifyProviderError{Message: "Shopify integration credentials missing"} } endpoint := fmt.Sprintf("https://%s/admin/api/%s/%s", shopDomain, c.apiVersion, path) if encoded := query.Encode(); encoded != "" { endpoint += "?" + encoded } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("X-Shopify-Access-Token", token) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, &ShopifyProviderError{Message: strings.TrimSpace(string(body))} } var payload map[string]interface{} if err := json.Unmarshal(body, &payload); err != nil { return nil, err } return payload, nil } func shopifyMapSlice(payload map[string]interface{}, key string) []map[string]interface{} { rawItems, _ := payload[key].([]interface{}) items := make([]map[string]interface{}, 0, len(rawItems)) for _, raw := range rawItems { if item, ok := raw.(map[string]interface{}); ok { items = append(items, item) } } return items }