feat(webhooks): align account payloads
This commit is contained in:
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WebhookSubscriptionService provides business logic for managing webhook subscriptions.
|
||||
@@ -16,6 +19,29 @@ type WebhookSubscriptionService struct {
|
||||
webhookSubRepo *repository.WebhookSubscriptionRepo
|
||||
}
|
||||
|
||||
var allowedWebhookSubscriptions = map[string]struct{}{
|
||||
"conversation_status_changed": {},
|
||||
"conversation_updated": {},
|
||||
"conversation_created": {},
|
||||
"contact_created": {},
|
||||
"contact_updated": {},
|
||||
"message_created": {},
|
||||
"message_updated": {},
|
||||
"webwidget_triggered": {},
|
||||
"inbox_created": {},
|
||||
"inbox_updated": {},
|
||||
"conversation_typing_on": {},
|
||||
"conversation_typing_off": {},
|
||||
}
|
||||
|
||||
// WebhookSubscriptionMutation is the Chatwoot account webhook create/update payload.
|
||||
type WebhookSubscriptionMutation struct {
|
||||
InboxID *uint `json:"inbox_id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Subscriptions []string `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// NewWebhookSubscriptionService creates a new WebhookSubscription service with required dependencies.
|
||||
func NewWebhookSubscriptionService(webhookSubRepo *repository.WebhookSubscriptionRepo) *WebhookSubscriptionService {
|
||||
return &WebhookSubscriptionService{
|
||||
@@ -29,8 +55,23 @@ func (s *WebhookSubscriptionService) ListSubscriptions(ctx context.Context, acco
|
||||
}
|
||||
|
||||
// CreateSubscription creates a new webhook subscription with a generated signing secret.
|
||||
func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, url string, events []string) (*model.WebhookSubscription, error) {
|
||||
eventsJSON, err := json.Marshal(events)
|
||||
func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, webhookURL string, events []string) (*model.WebhookSubscription, error) {
|
||||
req := WebhookSubscriptionMutation{URL: webhookURL, Subscriptions: events}
|
||||
return s.CreateWebhook(ctx, accountID, req)
|
||||
}
|
||||
|
||||
// CreateWebhook creates a Chatwoot-compatible account webhook.
|
||||
func (s *WebhookSubscriptionService) CreateWebhook(ctx context.Context, accountID uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) {
|
||||
if err := validateWebhookMutation(req, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != 0 {
|
||||
return nil, fmt.Errorf("url has already been taken")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eventsJSON, err := json.Marshal(req.Subscriptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal events: %w", err)
|
||||
}
|
||||
@@ -43,7 +84,9 @@ func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, acc
|
||||
|
||||
sub := &model.WebhookSubscription{
|
||||
AccountID: accountID,
|
||||
URL: url,
|
||||
InboxID: req.InboxID,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Events: eventsJSON,
|
||||
Secret: secret,
|
||||
Active: true,
|
||||
@@ -79,6 +122,51 @@ func (s *WebhookSubscriptionService) UpdateSubscription(ctx context.Context, id
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// UpdateWebhook updates a Chatwoot-compatible account webhook scoped to the account.
|
||||
func (s *WebhookSubscriptionService) UpdateWebhook(ctx context.Context, accountID, id uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) {
|
||||
if err := validateWebhookMutation(req, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find subscription: %w", err)
|
||||
}
|
||||
if req.URL != "" && req.URL != sub.URL {
|
||||
if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != sub.ID {
|
||||
return nil, fmt.Errorf("url has already been taken")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
sub.URL = req.URL
|
||||
}
|
||||
sub.Name = req.Name
|
||||
sub.InboxID = req.InboxID
|
||||
eventsJSON, err := json.Marshal(req.Subscriptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal events: %w", err)
|
||||
}
|
||||
sub.Events = eventsJSON
|
||||
sub.Active = true
|
||||
|
||||
if err := s.webhookSubRepo.Update(ctx, sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
|
||||
// DeleteWebhook deletes a Chatwoot-compatible account webhook scoped to the account.
|
||||
func (s *WebhookSubscriptionService) DeleteWebhook(ctx context.Context, accountID, id uint) error {
|
||||
if _, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id); err != nil {
|
||||
return fmt.Errorf("find subscription: %w", err)
|
||||
}
|
||||
return s.webhookSubRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// GetWebhook retrieves a Chatwoot-compatible account webhook scoped to the account.
|
||||
func (s *WebhookSubscriptionService) GetWebhook(ctx context.Context, accountID, id uint) (*model.WebhookSubscription, error) {
|
||||
return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
|
||||
// DeleteSubscription soft-deletes a webhook subscription.
|
||||
func (s *WebhookSubscriptionService) DeleteSubscription(ctx context.Context, id uint) error {
|
||||
return s.webhookSubRepo.Delete(ctx, id)
|
||||
@@ -101,4 +189,27 @@ func generateWebhookSecret() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateWebhookMutation(req WebhookSubscriptionMutation, requireURL bool) error {
|
||||
if requireURL || req.URL != "" {
|
||||
u, err := url.ParseRequestURI(req.URL)
|
||||
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("url is invalid")
|
||||
}
|
||||
}
|
||||
if len(req.Subscriptions) == 0 {
|
||||
return fmt.Errorf("subscriptions is invalid")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, subscription := range req.Subscriptions {
|
||||
if _, ok := allowedWebhookSubscriptions[subscription]; !ok {
|
||||
return fmt.Errorf("subscriptions is invalid")
|
||||
}
|
||||
if _, ok := seen[subscription]; ok {
|
||||
return fmt.Errorf("subscriptions is invalid")
|
||||
}
|
||||
seen[subscription] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestWebhookSubscriptionServiceChatwootParity(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WebhookSubscription{}, &model.WebhookDelivery{}))
|
||||
|
||||
account := model.Account{Name: "webhook-service-account"}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
inbox := model.Inbox{AccountID: account.ID, Name: "Support"}
|
||||
require.NoError(t, db.Create(&inbox).Error)
|
||||
|
||||
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
|
||||
created, err := svc.CreateWebhook(context.Background(), account.ID, WebhookSubscriptionMutation{
|
||||
InboxID: &inbox.ID,
|
||||
Name: "Service hook",
|
||||
URL: "https://example.com/service-hook",
|
||||
Subscriptions: []string{"message_created", "conversation_updated"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, created.Secret)
|
||||
require.Equal(t, "Service hook", created.Name)
|
||||
require.True(t, created.IsEventSubscribed("message_created"))
|
||||
|
||||
updated, err := svc.UpdateWebhook(context.Background(), account.ID, created.ID, WebhookSubscriptionMutation{
|
||||
Name: "Updated service hook",
|
||||
URL: "https://example.com/service-hook-updated",
|
||||
Subscriptions: []string{"contact_created"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Updated service hook", updated.Name)
|
||||
require.True(t, updated.IsEventSubscribed("contact_created"))
|
||||
require.False(t, updated.IsEventSubscribed("message_created"))
|
||||
|
||||
matches, err := repository.NewWebhookSubscriptionRepo(db).ListByAccountAndEvent(context.Background(), account.ID, "contact_created")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, matches, 1)
|
||||
|
||||
otherAccount := model.Account{Name: "other-webhook-service-account"}
|
||||
require.NoError(t, db.Create(&otherAccount).Error)
|
||||
require.Error(t, svc.DeleteWebhook(context.Background(), otherAccount.ID, created.ID))
|
||||
require.NoError(t, svc.DeleteWebhook(context.Background(), account.ID, created.ID))
|
||||
}
|
||||
|
||||
func TestWebhookSubscriptionServiceValidation(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.WebhookSubscription{}))
|
||||
|
||||
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
|
||||
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
|
||||
URL: "ftp://example.com/hook",
|
||||
Subscriptions: []string{"message_created"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
|
||||
URL: "https://example.com/hook",
|
||||
Subscriptions: []string{"not_allowed"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user