304 lines
10 KiB
Go
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
|
|
}
|