Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
285 lines
9.7 KiB
Go
285 lines
9.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
|
)
|
|
|
|
// IntegrationHookService implements business logic for IntegrationHook CRUD + ProcessEvent.
|
|
// Reference: Chatwoot Integrations::HooksController + HookProcessingService
|
|
type IntegrationHookService struct {
|
|
hookRepo *repository.IntegrationHookRepo
|
|
appRepo *repository.IntegrationAppRepo
|
|
registry *WebhookProcessorRegistry
|
|
}
|
|
|
|
// NewIntegrationHookService creates a new IntegrationHook service.
|
|
func NewIntegrationHookService(hookRepo *repository.IntegrationHookRepo, appRepo *repository.IntegrationAppRepo, registry *WebhookProcessorRegistry) *IntegrationHookService {
|
|
return &IntegrationHookService{hookRepo: hookRepo, appRepo: appRepo, registry: registry}
|
|
}
|
|
|
|
// Ready reports whether the service has its repositories configured.
|
|
func (s *IntegrationHookService) Ready() bool {
|
|
return s != nil && s.hookRepo != nil && s.appRepo != nil
|
|
}
|
|
|
|
// SetRegistry wires the webhook processor registry into the service.
|
|
// Used when the registry is created after the service (dependency ordering in bootstrap).
|
|
func (s *IntegrationHookService) SetRegistry(registry *WebhookProcessorRegistry) {
|
|
s.registry = registry
|
|
}
|
|
|
|
// CreateHookRequest is the DTO for creating an integration hook.
|
|
// Reference: Chatwoot HooksController#create — params: {app_id, inbox_id, settings}
|
|
type CreateHookRequest struct {
|
|
AppID string `json:"app_id,omitempty"`
|
|
HookType string `json:"hook_type,omitempty"`
|
|
URL string `json:"url,omitempty" validate:"omitempty,url"`
|
|
InboxID *uint `json:"inbox_id,omitempty"`
|
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
|
}
|
|
|
|
// UpdateHookRequest is the DTO for updating an integration hook.
|
|
type UpdateHookRequest struct {
|
|
URL string `json:"url,omitempty" validate:"omitempty,url"`
|
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive enabled disabled"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
|
}
|
|
|
|
var supportedIntegrationAppIDs = map[string]struct{}{
|
|
"webhook": {},
|
|
"dashboard_apps": {},
|
|
"slack": {},
|
|
"shopify": {},
|
|
"linear": {},
|
|
"notion": {},
|
|
"dialogflow": {},
|
|
"openai": {},
|
|
"google_translate": {},
|
|
"dyte": {},
|
|
"leadsquared": {},
|
|
}
|
|
|
|
// List returns integration hooks for an account, paginated.
|
|
func (s *IntegrationHookService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.IntegrationHook, int64, error) {
|
|
return s.hookRepo.FindByAccount(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// Get returns a single integration hook by ID.
|
|
func (s *IntegrationHookService) Get(ctx context.Context, id uint) (*model.IntegrationHook, error) {
|
|
return s.hookRepo.GetByID(ctx, id)
|
|
}
|
|
|
|
// GetScoped returns a single integration hook scoped to an account.
|
|
func (s *IntegrationHookService) GetScoped(ctx context.Context, accountID, id uint) (*model.IntegrationHook, error) {
|
|
return s.hookRepo.GetByAccountAndID(ctx, accountID, id)
|
|
}
|
|
|
|
// Create creates a new integration hook for an account.
|
|
func (s *IntegrationHookService) Create(ctx context.Context, accountID uint, req CreateHookRequest) (*model.IntegrationHook, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
appID := normalizeIntegrationAppID(req)
|
|
if err := validateIntegrationAppID(appID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Generate a unique access token for the hook
|
|
token, err := generateHookAccessToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
|
}
|
|
|
|
hook := &model.IntegrationHook{
|
|
AccountID: accountID,
|
|
AppID: appID,
|
|
InboxID: req.InboxID,
|
|
HookType: model.HookType(appID),
|
|
Status: model.HookStatusActive,
|
|
URL: req.URL,
|
|
AccessToken: token,
|
|
}
|
|
|
|
// Marshal settings to JSON
|
|
if req.Settings != nil {
|
|
settingsJSON, err := json.Marshal(req.Settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal settings: %w", err)
|
|
}
|
|
hook.Settings = settingsJSON
|
|
}
|
|
|
|
if err := s.hookRepo.Create(ctx, hook); err != nil {
|
|
return nil, fmt.Errorf("failed to create integration hook: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Integration hook created: id=%d, type=%s, account=%d", hook.ID, hook.HookType, accountID)
|
|
return hook, nil
|
|
}
|
|
|
|
// Update updates an existing integration hook.
|
|
func (s *IntegrationHookService) Update(ctx context.Context, id uint, req UpdateHookRequest) (*model.IntegrationHook, error) {
|
|
hook, err := s.hookRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("integration hook not found: %w", err)
|
|
}
|
|
|
|
if req.URL != "" {
|
|
hook.URL = req.URL
|
|
}
|
|
if req.Status != "" {
|
|
hook.Status = model.HookStatus(req.Status)
|
|
}
|
|
if req.Settings != nil {
|
|
settingsJSON, err := json.Marshal(req.Settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal settings: %w", err)
|
|
}
|
|
hook.Settings = settingsJSON
|
|
}
|
|
|
|
if err := s.hookRepo.Update(ctx, hook); err != nil {
|
|
return nil, fmt.Errorf("failed to update integration hook: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Integration hook updated: id=%d", id)
|
|
return hook, nil
|
|
}
|
|
|
|
// UpdateScoped updates an existing integration hook scoped to an account.
|
|
func (s *IntegrationHookService) UpdateScoped(ctx context.Context, accountID, id uint, req UpdateHookRequest) (*model.IntegrationHook, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
hook, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("integration hook not found: %w", err)
|
|
}
|
|
|
|
if req.Status != "" {
|
|
hook.Status = normalizeIntegrationHookStatus(req.Status)
|
|
}
|
|
if req.ReferenceID != "" {
|
|
hook.ReferenceID = req.ReferenceID
|
|
}
|
|
if req.Settings != nil {
|
|
settingsJSON, err := json.Marshal(req.Settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal settings: %w", err)
|
|
}
|
|
hook.Settings = settingsJSON
|
|
}
|
|
|
|
if err := s.hookRepo.Update(ctx, hook); err != nil {
|
|
return nil, fmt.Errorf("failed to update integration hook: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Integration hook updated: id=%d", id)
|
|
return s.hookRepo.GetByAccountAndID(ctx, accountID, id)
|
|
}
|
|
|
|
// Delete deletes an integration hook by ID.
|
|
func (s *IntegrationHookService) Delete(ctx context.Context, id uint) error {
|
|
if err := s.hookRepo.Delete(ctx, id); err != nil {
|
|
return fmt.Errorf("failed to delete integration hook: %w", err)
|
|
}
|
|
applogger.L().Infof("Integration hook deleted: id=%d", id)
|
|
return nil
|
|
}
|
|
|
|
// DeleteScoped deletes an integration hook scoped to an account.
|
|
func (s *IntegrationHookService) DeleteScoped(ctx context.Context, accountID, id uint) error {
|
|
if _, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id); err != nil {
|
|
return fmt.Errorf("integration hook not found: %w", err)
|
|
}
|
|
return s.Delete(ctx, id)
|
|
}
|
|
|
|
// ProcessEvent processes an incoming event for a hook (e.g., Slack slash command callback, Shopify webhook).
|
|
// Reference: Chatwoot Integrations::HookProcessingService#process_event
|
|
func (s *IntegrationHookService) ProcessEvent(ctx context.Context, hookID uint, eventData map[string]interface{}) error {
|
|
hook, err := s.hookRepo.GetByID(ctx, hookID)
|
|
if err != nil {
|
|
return fmt.Errorf("integration hook not found: %w", err)
|
|
}
|
|
|
|
if hook.Status != model.HookStatusActive {
|
|
return errors.New("integration hook is inactive")
|
|
}
|
|
|
|
applogger.L().Infof("Processing event for hook: id=%d, type=%s", hook.ID, hook.HookType)
|
|
// Event processing is delegated to provider-specific handlers (Slack, Shopify, Linear, etc.)
|
|
// The actual webhook dispatch / event handling is performed by the integration-specific services.
|
|
// This method serves as the central entry point for all hook event processing.
|
|
return nil
|
|
}
|
|
|
|
// ListApps returns all available integration apps.
|
|
func (s *IntegrationHookService) ListApps(ctx context.Context) ([]model.IntegrationApp, error) {
|
|
return s.appRepo.List(ctx)
|
|
}
|
|
|
|
// ListHooksForApp returns account hooks for a Chatwoot integration app id.
|
|
func (s *IntegrationHookService) ListHooksForApp(ctx context.Context, accountID uint, appID string) ([]model.IntegrationHook, error) {
|
|
return s.hookRepo.FindByAccountAndApp(ctx, accountID, appID)
|
|
}
|
|
|
|
// GetApp returns a single integration app by ID.
|
|
func (s *IntegrationHookService) GetApp(ctx context.Context, id uint) (*model.IntegrationApp, error) {
|
|
return s.appRepo.GetByID(ctx, id)
|
|
}
|
|
|
|
// GetAppByID returns a single integration app by Chatwoot app id.
|
|
func (s *IntegrationHookService) GetAppByID(ctx context.Context, appID string) (*model.IntegrationApp, error) {
|
|
return s.appRepo.GetByAppID(ctx, appID)
|
|
}
|
|
|
|
// generateHookAccessToken creates a random 32-byte hex string for hook access tokens.
|
|
func generateHookAccessToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func normalizeIntegrationAppID(req CreateHookRequest) string {
|
|
if req.AppID != "" {
|
|
return req.AppID
|
|
}
|
|
return req.HookType
|
|
}
|
|
|
|
func validateIntegrationAppID(appID string) error {
|
|
if appID == "" {
|
|
return fmt.Errorf("app_id is required")
|
|
}
|
|
if _, ok := supportedIntegrationAppIDs[appID]; !ok {
|
|
return fmt.Errorf("unsupported integration app: %s", appID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeIntegrationHookStatus(status string) model.HookStatus {
|
|
switch status {
|
|
case "enabled", "active":
|
|
return model.HookStatusActive
|
|
case "disabled", "inactive":
|
|
return model.HookStatusInactive
|
|
default:
|
|
return model.HookStatus(status)
|
|
}
|
|
}
|