Files
gochat/internal/service/shopify_integration_service.go
T

304 lines
10 KiB
Go

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
}