mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-07 21:07:56 +02:00
Add support for multiple webhooks per campaign
Add webhook links on campaign page Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
+29
-78
@@ -1190,31 +1190,17 @@ func (s *Server) checkAndServePhishingPage(
|
||||
return true, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
// handle webhook
|
||||
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
||||
c,
|
||||
// handle webhooks
|
||||
err = s.services.Campaign.HandleWebhooks(
|
||||
// TODO this should be tied to a application wide context not the request
|
||||
context.TODO(),
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
||||
webhookData,
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Errorw("failed to get webhook id by campaign id",
|
||||
"campaignID", campaignID.String(),
|
||||
"error", err,
|
||||
)
|
||||
return true, errs.Wrap(err)
|
||||
}
|
||||
if webhookID != nil {
|
||||
err = s.services.Campaign.HandleWebhook(
|
||||
// TODO this should be tied to a application wide context not the request
|
||||
context.TODO(),
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
||||
webhookData,
|
||||
)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to handle webhook: %s", err)
|
||||
}
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to handle webhooks: %s", err)
|
||||
}
|
||||
}
|
||||
// if redirect && data submission && final page
|
||||
@@ -1451,32 +1437,23 @@ func (s *Server) checkAndServePhishingPage(
|
||||
}
|
||||
}
|
||||
|
||||
// handle webhook for Proxy page visit
|
||||
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
||||
c,
|
||||
&campaignID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Errorw("failed to get webhook id by campaign id for proxy",
|
||||
"campaignID", campaignID.String(),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE {
|
||||
err = s.services.Campaign.HandleWebhook(
|
||||
// handle webhooks for Proxy page visit
|
||||
if currentPageType != data.PAGE_TYPE_DONE {
|
||||
err = s.services.Campaign.HandleWebhooks(
|
||||
// TODO this should be tied to a application wide context not the request
|
||||
context.TODO(),
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
eventName,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to handle webhook for Proxy page",
|
||||
s.logger.Errorw("failed to handle webhooks for Proxy page visit",
|
||||
"campaignID", campaignID.String(),
|
||||
"recipientID", recipientID.String(),
|
||||
"error", err,
|
||||
"proxyID", proxyID.String(),
|
||||
)
|
||||
return true, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1759,34 +1736,19 @@ func (s *Server) checkAndServePhishingPage(
|
||||
return true, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
// handle webhook
|
||||
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
||||
c,
|
||||
&campaignID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Errorw("failed to get webhook id by campaign id %s",
|
||||
"campaignID", campaignID.String(),
|
||||
"error", err,
|
||||
)
|
||||
return true, errs.Wrap(err)
|
||||
}
|
||||
if webhookID == nil {
|
||||
return true, nil
|
||||
}
|
||||
// handle webhooks
|
||||
// do not notify on visiting the page done as it is a repeat of the flow
|
||||
if currentPageType != data.PAGE_TYPE_DONE {
|
||||
err = s.services.Campaign.HandleWebhook(
|
||||
err = s.services.Campaign.HandleWebhooks(
|
||||
// TODO this should be tied to a application wide context not the request
|
||||
context.TODO(),
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
eventName,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to handle webhook: %s", err)
|
||||
return true, fmt.Errorf("failed to handle webhooks: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1982,32 +1944,21 @@ func (s *Server) renderDenyPage(
|
||||
recipientEmailStr = recipientEmailVal.String()
|
||||
}
|
||||
|
||||
// handle webhook for deny page visit
|
||||
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
||||
c,
|
||||
// handle webhooks for deny page visit
|
||||
err = s.services.Campaign.HandleWebhooks(
|
||||
context.TODO(),
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Errorw("failed to get webhook id by campaign id for deny page",
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to handle webhooks for deny page visit",
|
||||
"campaignID", campaignID.String(),
|
||||
"recipientID", recipientID.String(),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if webhookID != nil {
|
||||
err = s.services.Campaign.HandleWebhook(
|
||||
context.TODO(),
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to handle webhook for deny page visit",
|
||||
"error", err,
|
||||
"campaignRecipientID", campaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
// not returning error as we don't want to block page rendering
|
||||
}
|
||||
|
||||
s.logger.Debugw("rendered deny page",
|
||||
|
||||
@@ -180,6 +180,7 @@ func (c *Campaign) GetByID(g *gin.Context) {
|
||||
WithAllowDeny: true,
|
||||
WithDenyPage: true,
|
||||
WithEvasionPage: true,
|
||||
WithWebhooks: true,
|
||||
},
|
||||
)
|
||||
// handle responses
|
||||
|
||||
@@ -44,21 +44,11 @@ type Campaign struct {
|
||||
IsAnonymous bool `gorm:"not null;default:false"`
|
||||
IsTest bool `gorm:"not null;default:false"`
|
||||
Obfuscate bool `gorm:"not null;default:false"`
|
||||
WebhookIncludeData string `gorm:"not null;default:'full'"`
|
||||
// WebhookEvents is a binary format storing selected events as bits (10 events)
|
||||
// 0 = all events (default, backward compatible)
|
||||
// non-zero = only selected events trigger webhooks
|
||||
// bit 0 (1): campaign_closed
|
||||
// bit 1 (2): campaign_recipient_message_sent
|
||||
// bit 2 (4): campaign_recipient_message_failed
|
||||
// bit 3 (8): campaign_recipient_message_read
|
||||
// bit 4 (16): campaign_recipient_submitted_data
|
||||
// bit 5 (32): campaign_recipient_evasion_page_visited
|
||||
// bit 6 (64): campaign_recipient_before_page_visited
|
||||
// bit 7 (128): campaign_recipient_page_visited
|
||||
// bit 8 (256): campaign_recipient_after_page_visited
|
||||
// bit 9 (512): campaign_recipient_deny_page_visited
|
||||
WebhookEvents int `gorm:"not null;default:0"`
|
||||
|
||||
// deprecated: webhook settings moved to campaign_webhooks junction table
|
||||
// kept for backward compatibility during migration
|
||||
WebhookIncludeData string `gorm:"not null;default:'full'"`
|
||||
WebhookEvents int `gorm:"not null;default:0"`
|
||||
|
||||
// has one
|
||||
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
|
||||
@@ -75,11 +65,13 @@ type Campaign struct {
|
||||
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
|
||||
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
|
||||
// deprecated: kept for backward compatibility during migration
|
||||
WebhookID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
|
||||
// has many-to-many
|
||||
RecipientGroups []*RecipientGroup `gorm:"many2many:campaign_recipient_groups"`
|
||||
AllowDeny []*AllowDeny `gorm:"many2many:campaign_allow_denies"`
|
||||
Webhooks []*Webhook `gorm:"many2many:campaign_webhooks"`
|
||||
|
||||
Name string `gorm:"not null;uniqueIndex:idx_campaigns_unique_name_and_company_id"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CampaignWebhook is gorm data model
|
||||
// is a junction table for campaign-webhook many-to-many relationship
|
||||
// stores per-webhook configuration (events and data level)
|
||||
type CampaignWebhook struct {
|
||||
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_webhook;primaryKey;"`
|
||||
Campaign *Campaign
|
||||
|
||||
WebhookID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_webhook;primaryKey;"`
|
||||
Webhook *Webhook
|
||||
|
||||
// webhookincludedata is the data level to include in webhook payload
|
||||
// values: "none", "basic", "full"
|
||||
WebhookIncludeData string `gorm:"not null;default:'full'"`
|
||||
|
||||
// webhookevents is a binary format storing selected events as bits (10 events)
|
||||
// 0 = all events (default, backward compatible)
|
||||
// non-zero = only selected events trigger webhooks
|
||||
// bit 0 (1): campaign_closed
|
||||
// bit 1 (2): campaign_recipient_message_sent
|
||||
// bit 2 (4): campaign_recipient_message_failed
|
||||
// bit 3 (8): campaign_recipient_message_read
|
||||
// bit 4 (16): campaign_recipient_submitted_data
|
||||
// bit 5 (32): campaign_recipient_evasion_page_visited
|
||||
// bit 6 (64): campaign_recipient_before_page_visited
|
||||
// bit 7 (128): campaign_recipient_page_visited
|
||||
// bit 8 (256): campaign_recipient_after_page_visited
|
||||
// bit 9 (512): campaign_recipient_deny_page_visited
|
||||
WebhookEvents int `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
func (CampaignWebhook) TableName() string {
|
||||
return "campaign_webhooks"
|
||||
}
|
||||
+25
-20
@@ -38,26 +38,31 @@ type Campaign struct {
|
||||
|
||||
Name nullable.Nullable[vo.String64] `json:"name"`
|
||||
|
||||
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
|
||||
SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"`
|
||||
IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"`
|
||||
IsTest nullable.Nullable[bool] `json:"isTest"`
|
||||
Obfuscate nullable.Nullable[bool] `json:"obfuscate"`
|
||||
WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData"`
|
||||
WebhookEvents nullable.Nullable[int] `json:"webhookEvents"`
|
||||
TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"`
|
||||
Template *CampaignTemplate `json:"template"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Company *Company `json:"company"`
|
||||
RecipientGroups []*RecipientGroup `json:"recipientGroups"`
|
||||
RecipientGroupIDs nullable.Nullable[[]*uuid.UUID] `json:"recipientGroupIDs,omitempty"`
|
||||
AllowDeny []*AllowDeny `json:"allowDeny"`
|
||||
AllowDenyIDs nullable.Nullable[[]*uuid.UUID] `json:"allowDenyIDs,omitempty"`
|
||||
DenyPageID nullable.Nullable[uuid.UUID] `json:"denyPageID,omitempty"`
|
||||
DenyPage *Page `json:"denyPage"`
|
||||
EvasionPageID nullable.Nullable[uuid.UUID] `json:"evasionPageID,omitempty"`
|
||||
EvasionPage *Page `json:"evasionPage"`
|
||||
WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID"`
|
||||
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
|
||||
SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"`
|
||||
IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"`
|
||||
IsTest nullable.Nullable[bool] `json:"isTest"`
|
||||
Obfuscate nullable.Nullable[bool] `json:"obfuscate"`
|
||||
// deprecated: use Webhooks array instead for multiple webhooks with per-webhook settings
|
||||
WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData,omitempty"`
|
||||
WebhookEvents nullable.Nullable[int] `json:"webhookEvents,omitempty"`
|
||||
WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID,omitempty"`
|
||||
|
||||
TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"`
|
||||
Template *CampaignTemplate `json:"template"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Company *Company `json:"company"`
|
||||
RecipientGroups []*RecipientGroup `json:"recipientGroups"`
|
||||
RecipientGroupIDs nullable.Nullable[[]*uuid.UUID] `json:"recipientGroupIDs,omitempty"`
|
||||
AllowDeny []*AllowDeny `json:"allowDeny"`
|
||||
AllowDenyIDs nullable.Nullable[[]*uuid.UUID] `json:"allowDenyIDs,omitempty"`
|
||||
DenyPageID nullable.Nullable[uuid.UUID] `json:"denyPageID,omitempty"`
|
||||
DenyPage *Page `json:"denyPage"`
|
||||
EvasionPageID nullable.Nullable[uuid.UUID] `json:"evasionPageID,omitempty"`
|
||||
EvasionPage *Page `json:"evasionPage"`
|
||||
|
||||
// webhooks configuration with per-webhook settings
|
||||
Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"`
|
||||
|
||||
// must not be set by a user
|
||||
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
)
|
||||
|
||||
// CampaignWebhook represents a webhook configuration for a campaign
|
||||
// allows per-webhook event and data level settings
|
||||
type CampaignWebhook struct {
|
||||
WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID"`
|
||||
WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData"`
|
||||
WebhookEvents nullable.Nullable[int] `json:"webhookEvents"`
|
||||
}
|
||||
|
||||
// Validate checks if the campaign webhook has valid configuration
|
||||
func (cw *CampaignWebhook) Validate() error {
|
||||
if err := validate.NullableFieldRequired("webhookID", cw.WebhookID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// validate webhookincludedata is one of the allowed values
|
||||
if cw.WebhookIncludeData.IsSpecified() && !cw.WebhookIncludeData.IsNull() {
|
||||
dataLevel := cw.WebhookIncludeData.MustGet()
|
||||
if dataLevel != WebhookDataLevelNone &&
|
||||
dataLevel != WebhookDataLevelBasic &&
|
||||
dataLevel != WebhookDataLevelFull {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("must be 'none', 'basic', or 'full'"),
|
||||
"webhookIncludeData",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// validate webhookevents is a valid binary value
|
||||
if cw.WebhookEvents.IsSpecified() && !cw.WebhookEvents.IsNull() {
|
||||
events := cw.WebhookEvents.MustGet()
|
||||
// check if any invalid bits are set (only bits 0-9 are valid)
|
||||
maxValidBits := 0
|
||||
for _, bit := range data.WebhookEventToBit {
|
||||
maxValidBits |= bit
|
||||
}
|
||||
if events < 0 || (events > 0 && events&^maxValidBits != 0) {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid webhook events binary value"),
|
||||
"webhookEvents",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebhookIncludeDataOrDefault returns the data level or default to "full"
|
||||
func (cw *CampaignWebhook) GetWebhookIncludeDataOrDefault() string {
|
||||
if cw.WebhookIncludeData.IsSpecified() && !cw.WebhookIncludeData.IsNull() {
|
||||
return cw.WebhookIncludeData.MustGet()
|
||||
}
|
||||
return WebhookDataLevelFull
|
||||
}
|
||||
|
||||
// GetWebhookEventsOrDefault returns the webhook events binary or default to 0 (all events)
|
||||
func (cw *CampaignWebhook) GetWebhookEventsOrDefault() int {
|
||||
if cw.WebhookEvents.IsSpecified() && !cw.WebhookEvents.IsNull() {
|
||||
return cw.WebhookEvents.MustGet()
|
||||
}
|
||||
return 0 // 0 means all events
|
||||
}
|
||||
+35
-76
@@ -3428,30 +3428,20 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession,
|
||||
m.logger.Errorw("failed to create campaign submit event", "error", err)
|
||||
}
|
||||
|
||||
// handle webhook for submitted data event
|
||||
webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy submit",
|
||||
"campaignID", session.CampaignID.String(),
|
||||
// handle webhooks for submitted data event
|
||||
err = m.CampaignService.HandleWebhooks(
|
||||
ctx,
|
||||
session.CampaignID,
|
||||
session.RecipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
||||
capturedData,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhooks for MITM proxy submit",
|
||||
"error", err,
|
||||
"campaignRecipientID", session.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
if webhookID != nil {
|
||||
err = m.CampaignService.HandleWebhook(
|
||||
ctx,
|
||||
webhookID,
|
||||
session.CampaignID,
|
||||
session.RecipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
||||
capturedData,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhook for MITM proxy submit",
|
||||
"error", err,
|
||||
"campaignRecipientID", session.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) parseProxyConfig(configStr string) (*service.ProxyServiceConfigYAML, error) {
|
||||
@@ -4181,27 +4171,18 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic
|
||||
}
|
||||
}
|
||||
|
||||
// handle webhook for MITM page visit
|
||||
webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy",
|
||||
"campaignID", session.CampaignID.String(),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE {
|
||||
err = m.CampaignService.HandleWebhook(
|
||||
// handle webhooks for MITM page visit
|
||||
if currentPageType != data.PAGE_TYPE_DONE {
|
||||
err = m.CampaignService.HandleWebhooks(
|
||||
ctx,
|
||||
webhookID,
|
||||
session.CampaignID,
|
||||
session.RecipientID,
|
||||
eventName,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhook for MITM page visit",
|
||||
m.logger.Errorw("failed to handle webhooks for MITM page visit",
|
||||
"error", err,
|
||||
"campaignRecipientID", session.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4742,30 +4723,19 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt
|
||||
}
|
||||
}
|
||||
|
||||
// handle webhook for deny page visit
|
||||
webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
m.logger.Errorw("failed to get webhook id by campaign id for deny page",
|
||||
"campaignID", campaignID.String(),
|
||||
// handle webhooks for deny page visit
|
||||
err = m.CampaignService.HandleWebhooks(
|
||||
req.Context(),
|
||||
campaignID,
|
||||
recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhooks for deny page visit",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if webhookID != nil {
|
||||
err = m.CampaignService.HandleWebhook(
|
||||
req.Context(),
|
||||
webhookID,
|
||||
campaignID,
|
||||
recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhook for deny page visit",
|
||||
"error", err,
|
||||
"campaignRecipientID", reqCtx.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, reqCtx *RequestContext) {
|
||||
@@ -4815,30 +4785,19 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re
|
||||
m.logger.Errorw("failed to save evasion page visit event", "error", err)
|
||||
}
|
||||
|
||||
// handle webhook for evasion page visit
|
||||
webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
m.logger.Errorw("failed to get webhook id by campaign id for evasion page",
|
||||
"campaignID", campaignID.String(),
|
||||
// handle webhooks for evasion page visit
|
||||
err = m.CampaignService.HandleWebhooks(
|
||||
req.Context(),
|
||||
campaignID,
|
||||
recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhooks for evasion page visit",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if webhookID != nil {
|
||||
err = m.CampaignService.HandleWebhook(
|
||||
req.Context(),
|
||||
webhookID,
|
||||
campaignID,
|
||||
recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhook for evasion page visit",
|
||||
"error", err,
|
||||
"campaignRecipientID", reqCtx.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkFilter checks if the client IP, JA4 fingerprint and geo ip are allowed for proxy requests
|
||||
|
||||
@@ -60,6 +60,7 @@ type CampaignOption struct {
|
||||
WithAllowDeny bool
|
||||
WithDenyPage bool
|
||||
WithEvasionPage bool
|
||||
WithWebhooks bool
|
||||
IncludeTestCampaigns bool
|
||||
}
|
||||
|
||||
@@ -117,6 +118,9 @@ func (r *Campaign) load(db *gorm.DB, options *CampaignOption) *gorm.DB {
|
||||
if options.WithEvasionPage {
|
||||
db = db.Preload("EvasionPage")
|
||||
}
|
||||
// WithWebhooks is handled post-query via GetCampaignWebhooks
|
||||
// because the junction table stores extra columns (include_data, events)
|
||||
// that GORM many2many preload cannot populate into model.CampaignWebhook
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -253,6 +257,85 @@ func (r *Campaign) AddAllowDenyLists(
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddWebhooks adds webhooks to campaign with per-webhook configuration
|
||||
func (r *Campaign) AddWebhooks(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
webhooks []*model.CampaignWebhook,
|
||||
) error {
|
||||
batch := []database.CampaignWebhook{}
|
||||
// deduplicate by webhook id to prevent unique constraint violations
|
||||
seen := map[string]struct{}{}
|
||||
for _, wh := range webhooks {
|
||||
webhookID := wh.WebhookID.MustGet()
|
||||
key := webhookID.String()
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
batch = append(batch, database.CampaignWebhook{
|
||||
CampaignID: campaignID,
|
||||
WebhookID: &webhookID,
|
||||
WebhookIncludeData: wh.GetWebhookIncludeDataOrDefault(),
|
||||
WebhookEvents: wh.GetWebhookEventsOrDefault(),
|
||||
})
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
res := r.DB.Create(&batch)
|
||||
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveWebhooksByCampaignID removes all webhooks from a campaign
|
||||
func (r *Campaign) RemoveWebhooksByCampaignID(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
) error {
|
||||
res := r.DB.
|
||||
Where("campaign_id = ?", campaignID).
|
||||
Delete(&database.CampaignWebhook{})
|
||||
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCampaignWebhooks fetches webhook configurations for a campaign from junction table
|
||||
func (r *Campaign) GetCampaignWebhooks(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
) ([]*model.CampaignWebhook, error) {
|
||||
var rows []database.CampaignWebhook
|
||||
res := r.DB.
|
||||
Where("campaign_id = ?", campaignID.String()).
|
||||
Find(&rows)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
|
||||
webhooks := []*model.CampaignWebhook{}
|
||||
for _, row := range rows {
|
||||
if row.WebhookID == nil {
|
||||
continue
|
||||
}
|
||||
wh := &model.CampaignWebhook{
|
||||
WebhookID: nullable.NewNullableWithValue(*row.WebhookID),
|
||||
WebhookIncludeData: nullable.NewNullableWithValue(row.WebhookIncludeData),
|
||||
WebhookEvents: nullable.NewNullableWithValue(row.WebhookEvents),
|
||||
}
|
||||
webhooks = append(webhooks, wh)
|
||||
}
|
||||
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
// GetByWebhookID gets campaigns by webhook ID
|
||||
// not paginated
|
||||
func (r *Campaign) GetByWebhookID(
|
||||
@@ -261,8 +344,41 @@ func (r *Campaign) GetByWebhookID(
|
||||
) ([]*model.Campaign, error) {
|
||||
rows := []*database.Campaign{}
|
||||
models := []*model.Campaign{}
|
||||
|
||||
// use a map to deduplicate campaign IDs collected from both the junction
|
||||
// table and the legacy webhook_id column - after migration a campaign will
|
||||
// appear in both sources, so deduplication is required before the final query
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
// get from junction table
|
||||
var junctionRows []database.CampaignWebhook
|
||||
r.DB.Where("webhook_id = ?", webhookID.String()).Find(&junctionRows)
|
||||
for _, row := range junctionRows {
|
||||
if row.CampaignID != nil {
|
||||
seen[row.CampaignID.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// get from old webhook_id field (backward compatibility)
|
||||
var oldRows []*database.Campaign
|
||||
r.DB.Where("webhook_id = ?", webhookID.String()).Find(&oldRows)
|
||||
for _, row := range oldRows {
|
||||
if row.ID != nil {
|
||||
seen[row.ID.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
campaignIDs := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
campaignIDs = append(campaignIDs, id)
|
||||
}
|
||||
|
||||
res := r.DB.
|
||||
Where("webhook_id = ?", webhookID.String()).
|
||||
Where("id IN ?", campaignIDs).
|
||||
Find(&rows)
|
||||
|
||||
if res.Error != nil {
|
||||
@@ -348,11 +464,30 @@ func (r *Campaign) GetByTemplateIDs(
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// RemoveWebhookFromJunctionByWebhookID removes all campaign_webhooks rows for a given webhook id
|
||||
// must be called before deleting a webhook to avoid orphaned junction rows
|
||||
func (r *Campaign) RemoveWebhookFromJunctionByWebhookID(
|
||||
ctx context.Context,
|
||||
webhookID *uuid.UUID,
|
||||
) error {
|
||||
res := r.DB.
|
||||
Where("webhook_id = ?", webhookID.String()).
|
||||
Delete(&database.CampaignWebhook{})
|
||||
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveWebhookByCampaignIDs removes the webhook from campaigns by ids
|
||||
func (r *Campaign) RemoveWebhookByCampaignIDs(
|
||||
ctx context.Context,
|
||||
campaignIDs []*uuid.UUID,
|
||||
) error {
|
||||
if len(campaignIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
row := map[string]interface{}{}
|
||||
ids := UUIDsToStrings(campaignIDs)
|
||||
AddUpdatedAt(row)
|
||||
@@ -1084,7 +1219,18 @@ func (r *Campaign) GetByID(
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToCampaign(&dbCampaign)
|
||||
campaign, err := ToCampaign(&dbCampaign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.WithWebhooks {
|
||||
webhooks, err := r.GetCampaignWebhooks(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
campaign.Webhooks = nullable.NewNullableWithValue(webhooks)
|
||||
}
|
||||
return campaign, nil
|
||||
}
|
||||
|
||||
// GetNameByID gets a campaign name by id
|
||||
@@ -1652,6 +1798,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous)
|
||||
isTest := nullable.NewNullableWithValue(row.IsTest)
|
||||
obfuscate := nullable.NewNullableWithValue(row.Obfuscate)
|
||||
// deprecated fields - kept for backward compatibility
|
||||
webhookIncludeData := nullable.NewNullableWithValue(row.WebhookIncludeData)
|
||||
webhookEvents := nullable.NewNullableWithValue(row.WebhookEvents)
|
||||
var templateID nullable.Nullable[uuid.UUID]
|
||||
@@ -1740,10 +1887,14 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
}
|
||||
constraintEndTime.Set(*t)
|
||||
}
|
||||
// handle old single webhook (backward compatibility)
|
||||
webhookID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.WebhookID != nil {
|
||||
webhookID.Set(*row.WebhookID)
|
||||
}
|
||||
|
||||
// handle new multiple webhooks - will be populated separately if needed
|
||||
webhooks := nullable.NewNullNullable[[]*model.CampaignWebhook]()
|
||||
anonymizeAt := nullable.NewNullNullable[time.Time]()
|
||||
if row.AnonymizeAt != nil {
|
||||
anonymizeAt.Set(*row.AnonymizeAt)
|
||||
@@ -1797,6 +1948,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
EvasionPage: evasionPage,
|
||||
EvasionPageID: evasionPageID,
|
||||
WebhookID: webhookID,
|
||||
Webhooks: webhooks,
|
||||
NotableEventID: notableEventID,
|
||||
NotableEventName: notableEventName,
|
||||
}, nil
|
||||
|
||||
@@ -127,6 +127,36 @@ func (r *Webhook) GetByID(
|
||||
return ToWebhook(&row), nil
|
||||
}
|
||||
|
||||
// GetByIDs fetches multiple webhooks by their IDs in a single query
|
||||
func (r *Webhook) GetByIDs(
|
||||
ctx context.Context,
|
||||
ids []*uuid.UUID,
|
||||
) ([]*model.Webhook, error) {
|
||||
out := []*model.Webhook{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
idStrings := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
idStrings = append(idStrings, id.String())
|
||||
}
|
||||
var rows []*database.Webhook
|
||||
res := r.DB.
|
||||
Where(
|
||||
fmt.Sprintf("%s IN ?", TableColumnID(database.WEBHOOK_TABLE)),
|
||||
idStrings,
|
||||
).
|
||||
Find(&rows)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
for _, row := range rows {
|
||||
out = append(out, ToWebhook(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetByNames gets webhooks by names
|
||||
func (r *Webhook) GetByName(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -52,6 +52,7 @@ func initialInstallAndSeed(
|
||||
&database.AllowDeny{},
|
||||
&database.CampaignAllowDeny{},
|
||||
&database.Webhook{},
|
||||
&database.CampaignWebhook{},
|
||||
&database.Identifier{},
|
||||
&database.CampaignStats{},
|
||||
&database.OAuthProvider{},
|
||||
@@ -126,6 +127,13 @@ func initialInstallAndSeed(
|
||||
errors.Errorf("failed to seed identifiers: %w", err),
|
||||
)
|
||||
}
|
||||
// run data migrations (idempotent - safe to run on every startup)
|
||||
err = migrate(db)
|
||||
if err != nil {
|
||||
return errs.Wrap(
|
||||
errors.Errorf("failed to run data migrations: %w", err),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -398,5 +406,19 @@ func migrate(db *gorm.DB) error {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// migration for converting single webhook to multiple webhooks
|
||||
// migrate existing campaigns with webhook_id to campaign_webhooks junction table
|
||||
// ON CONFLICT DO NOTHING makes this idempotent on re-runs without relying on
|
||||
// db-driver-specific error message strings
|
||||
if err := db.Exec(`
|
||||
INSERT INTO campaign_webhooks (campaign_id, webhook_id, webhook_include_data, webhook_events)
|
||||
SELECT id, webhook_id, webhook_include_data, webhook_events
|
||||
FROM campaigns
|
||||
WHERE webhook_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
`).Error; err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+245
-135
@@ -77,13 +77,36 @@ func (c *Campaign) Create(
|
||||
if len(campaign.RecipientGroupIDs) == 0 {
|
||||
return nil, validate.WrapErrorWithField(errors.New("no groups provided"), "Recipient Groups")
|
||||
}
|
||||
// set default webhookIncludeData for backward compatibility
|
||||
if !campaign.WebhookIncludeData.IsSpecified() {
|
||||
campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull)
|
||||
|
||||
// handle backward compatibility: convert old single webhook to new array format
|
||||
if campaign.WebhookID.IsSpecified() && !campaign.WebhookID.IsNull() {
|
||||
webhookID := campaign.WebhookID.MustGet()
|
||||
// set default values for old fields if not specified
|
||||
if !campaign.WebhookIncludeData.IsSpecified() {
|
||||
campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull)
|
||||
}
|
||||
if !campaign.WebhookEvents.IsSpecified() {
|
||||
campaign.WebhookEvents.Set(0)
|
||||
}
|
||||
// convert to new array format
|
||||
webhookConfig := &model.CampaignWebhook{
|
||||
WebhookID: nullable.NewNullableWithValue(webhookID),
|
||||
WebhookIncludeData: campaign.WebhookIncludeData,
|
||||
WebhookEvents: campaign.WebhookEvents,
|
||||
}
|
||||
campaign.Webhooks.Set([]*model.CampaignWebhook{webhookConfig})
|
||||
// clear old fields
|
||||
campaign.WebhookID.SetNull()
|
||||
}
|
||||
// set default webhookEvents (0 means all events) for backward compatibility
|
||||
if !campaign.WebhookEvents.IsSpecified() {
|
||||
campaign.WebhookEvents.Set(0)
|
||||
|
||||
// validate new webhooks array
|
||||
if campaign.Webhooks.IsSpecified() && !campaign.Webhooks.IsNull() {
|
||||
webhooks := campaign.Webhooks.MustGet()
|
||||
for _, wh := range webhooks {
|
||||
if err := wh.Validate(); err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if the schedule type is scheduled, set the start time to start of day and end to the end of the last day
|
||||
if campaign.ConstraintWeekDays.IsSpecified() && !campaign.ConstraintWeekDays.IsNull() {
|
||||
@@ -165,6 +188,18 @@ func (c *Campaign) Create(
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// save webhook configurations if present
|
||||
if campaign.Webhooks.IsSpecified() && !campaign.Webhooks.IsNull() {
|
||||
webhooks := campaign.Webhooks.MustGet()
|
||||
if len(webhooks) > 0 {
|
||||
err = c.CampaignRepository.AddWebhooks(ctx, id, webhooks)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to add webhooks to campaign", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createdCampaign, err := c.CampaignRepository.GetByID(
|
||||
ctx,
|
||||
id,
|
||||
@@ -1251,18 +1286,9 @@ func (c *Campaign) SaveTrackingPixelLoaded(
|
||||
// logging was done in the previous call
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// handle webhook
|
||||
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil {
|
||||
return nil
|
||||
}
|
||||
err = c.HandleWebhook(
|
||||
// handle webhooks
|
||||
err = c.HandleWebhooks(
|
||||
ctx,
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ,
|
||||
@@ -1383,12 +1409,9 @@ func (c *Campaign) UpdateByID(
|
||||
if v, err := incoming.Obfuscate.Get(); err == nil {
|
||||
current.Obfuscate.Set(v)
|
||||
}
|
||||
if v, err := incoming.WebhookIncludeData.Get(); err == nil {
|
||||
current.WebhookIncludeData.Set(v)
|
||||
}
|
||||
if v, err := incoming.WebhookEvents.Get(); err == nil {
|
||||
current.WebhookEvents.Set(v)
|
||||
}
|
||||
// deprecated: webhookIncludeData and webhookEvents on the campaign root are not
|
||||
// written back - they are only read for backward-compat in HandleWebhooks.
|
||||
// per-webhook settings are managed via the webhooks array and campaign_webhooks table.
|
||||
if v, err := incoming.TemplateID.Get(); err == nil {
|
||||
current.TemplateID.Set(v)
|
||||
}
|
||||
@@ -1425,18 +1448,48 @@ func (c *Campaign) UpdateByID(
|
||||
if v, err := incoming.AnonymizedAt.Get(); err == nil {
|
||||
current.AnonymizedAt.Set(v.Truncate(time.Minute))
|
||||
}
|
||||
if v, err := incoming.TemplateID.Get(); err == nil {
|
||||
current.TemplateID.Set(v)
|
||||
}
|
||||
if v, err := incoming.RecipientGroupIDs.Get(); err == nil {
|
||||
current.RecipientGroupIDs.Set(v)
|
||||
}
|
||||
// handle webhook ID
|
||||
if incoming.WebhookID.IsSpecified() {
|
||||
if incoming.WebhookID.IsNull() {
|
||||
current.WebhookID.SetNull()
|
||||
// handle backward compatibility: convert old single webhook to new array format
|
||||
if incoming.WebhookID.IsSpecified() && !incoming.WebhookID.IsNull() {
|
||||
webhookID := incoming.WebhookID.MustGet()
|
||||
// set default values for old fields if not specified
|
||||
webhookIncludeData := model.WebhookDataLevelFull
|
||||
if incoming.WebhookIncludeData.IsSpecified() && !incoming.WebhookIncludeData.IsNull() {
|
||||
webhookIncludeData = incoming.WebhookIncludeData.MustGet()
|
||||
}
|
||||
webhookEvents := 0
|
||||
if incoming.WebhookEvents.IsSpecified() && !incoming.WebhookEvents.IsNull() {
|
||||
webhookEvents = incoming.WebhookEvents.MustGet()
|
||||
}
|
||||
// convert to new array format
|
||||
webhookConfig := &model.CampaignWebhook{
|
||||
WebhookID: nullable.NewNullableWithValue(webhookID),
|
||||
WebhookIncludeData: nullable.NewNullableWithValue(webhookIncludeData),
|
||||
WebhookEvents: nullable.NewNullableWithValue(webhookEvents),
|
||||
}
|
||||
incoming.Webhooks.Set([]*model.CampaignWebhook{webhookConfig})
|
||||
// clear old fields
|
||||
incoming.WebhookID.SetNull()
|
||||
}
|
||||
|
||||
// handle new webhooks array
|
||||
if incoming.Webhooks.IsSpecified() {
|
||||
// always clear the deprecated webhook_id column when the new format is used
|
||||
// so the legacy fallback in HandleWebhooks never reads a stale value
|
||||
current.WebhookID.SetNull()
|
||||
if incoming.Webhooks.IsNull() || len(incoming.Webhooks.MustGet()) == 0 {
|
||||
current.Webhooks.SetNull()
|
||||
} else {
|
||||
current.WebhookID.Set(incoming.WebhookID.MustGet())
|
||||
webhooks := incoming.Webhooks.MustGet()
|
||||
// validate each webhook
|
||||
for _, wh := range webhooks {
|
||||
if err := wh.Validate(); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
current.Webhooks.Set(webhooks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,6 +1540,27 @@ func (c *Campaign) UpdateByID(
|
||||
c.Logger.Errorw("failed to update campaign by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// update webhook configurations
|
||||
if current.Webhooks.IsSpecified() {
|
||||
// remove all existing webhooks
|
||||
err = c.CampaignRepository.RemoveWebhooksByCampaignID(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to remove webhooks from campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// add new webhooks if present
|
||||
if !current.Webhooks.IsNull() {
|
||||
webhooks := current.Webhooks.MustGet()
|
||||
if len(webhooks) > 0 {
|
||||
err = c.CampaignRepository.AddWebhooks(ctx, id, webhooks)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to add webhooks to campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// re-schedule the campaign
|
||||
// TODO should this all be in the schedule method
|
||||
// remove all existing schedules if the campaign is not self-managed
|
||||
@@ -2580,18 +2654,9 @@ func (c *Campaign) saveSendingResult(
|
||||
// logging was done in the previous call
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// handle webhook
|
||||
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if webhookID == nil {
|
||||
return nil
|
||||
}
|
||||
err = c.HandleWebhook(
|
||||
// handle webhooks
|
||||
err = c.HandleWebhooks(
|
||||
ctx,
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
eventName,
|
||||
@@ -2629,18 +2694,9 @@ func (c *Campaign) saveEventCampaignClose(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save event: %s", err)
|
||||
}
|
||||
// handle webhook
|
||||
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if webhookID == nil {
|
||||
return nil
|
||||
}
|
||||
err = c.HandleWebhook(
|
||||
// handle webhooks
|
||||
err = c.HandleWebhooks(
|
||||
ctx,
|
||||
webhookID,
|
||||
campaignID,
|
||||
nil,
|
||||
data.EVENT_CAMPAIGN_CLOSED,
|
||||
@@ -3338,18 +3394,9 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
// handle webhook
|
||||
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil {
|
||||
return nil
|
||||
}
|
||||
err = c.HandleWebhook(
|
||||
// handle webhooks
|
||||
err = c.HandleWebhooks(
|
||||
ctx,
|
||||
webhookID,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT,
|
||||
@@ -3361,8 +3408,138 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleWebhook handles a webhook
|
||||
// it must only be called from secure contexts as it is not checked for permissions
|
||||
// HandleWebhooks handles multiple webhooks for a campaign event
|
||||
func (c *Campaign) HandleWebhooks(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
eventName string,
|
||||
capturedData map[string]interface{},
|
||||
) error {
|
||||
// get campaign webhooks from junction table
|
||||
webhooks, err := c.CampaignRepository.GetCampaignWebhooks(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign webhooks", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// if no webhooks in new format, check for old single webhook (backward compatibility)
|
||||
if len(webhooks) == 0 {
|
||||
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// check if old webhook_id is set
|
||||
if campaign.WebhookID.IsSpecified() && !campaign.WebhookID.IsNull() {
|
||||
webhookID := campaign.WebhookID.MustGet()
|
||||
// use old webhook fields for backward compatibility
|
||||
webhookIncludeData := model.WebhookDataLevelFull
|
||||
if level, err := campaign.WebhookIncludeData.Get(); err == nil {
|
||||
webhookIncludeData = level
|
||||
}
|
||||
webhookEvents := 0
|
||||
if events, err := campaign.WebhookEvents.Get(); err == nil {
|
||||
webhookEvents = events
|
||||
}
|
||||
webhooks = []*model.CampaignWebhook{{
|
||||
WebhookID: nullable.NewNullableWithValue(webhookID),
|
||||
WebhookIncludeData: nullable.NewNullableWithValue(webhookIncludeData),
|
||||
WebhookEvents: nullable.NewNullableWithValue(webhookEvents),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
if len(webhooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get campaign name once for all webhooks
|
||||
campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// get email once for all webhooks
|
||||
var email *vo.Email
|
||||
if recipientID != nil {
|
||||
email, err = c.RecipientRepository.GetEmailByID(ctx, recipientID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
// batch-fetch all webhook details in one query to avoid N+1 db calls
|
||||
webhookIDs := make([]*uuid.UUID, 0, len(webhooks))
|
||||
for _, wc := range webhooks {
|
||||
id := wc.WebhookID.MustGet()
|
||||
webhookIDs = append(webhookIDs, &id)
|
||||
}
|
||||
webhookDetails, err := c.WebhookRepository.GetByIDs(ctx, webhookIDs)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to batch-fetch webhook details", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// index fetched webhooks by id for O(1) lookup inside the loop
|
||||
webhookByID := make(map[string]*model.Webhook, len(webhookDetails))
|
||||
for _, wd := range webhookDetails {
|
||||
webhookByID[wd.ID.MustGet().String()] = wd
|
||||
}
|
||||
|
||||
// send to each configured webhook
|
||||
for _, webhookConfig := range webhooks {
|
||||
webhookID := webhookConfig.WebhookID.MustGet()
|
||||
webhookEvents := webhookConfig.GetWebhookEventsOrDefault()
|
||||
dataLevel := webhookConfig.GetWebhookIncludeDataOrDefault()
|
||||
|
||||
// check if this event should trigger this webhook
|
||||
if !model.IsWebhookEventEnabled(webhookEvents, eventName) {
|
||||
continue
|
||||
}
|
||||
|
||||
webhook, ok := webhookByID[webhookID.String()]
|
||||
if !ok {
|
||||
c.Logger.Errorw("webhook not found in batch result", "webhookID", webhookID.String())
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
webhookReq := WebhookRequest{
|
||||
Time: &now,
|
||||
Event: eventName,
|
||||
}
|
||||
|
||||
// apply data level filters
|
||||
switch dataLevel {
|
||||
case model.WebhookDataLevelNone:
|
||||
// only time and event - no campaign name, no email, no data
|
||||
case model.WebhookDataLevelBasic:
|
||||
// include campaign name but no email or captured data
|
||||
webhookReq.CampaignName = campaignName
|
||||
case model.WebhookDataLevelFull:
|
||||
// include everything
|
||||
webhookReq.CampaignName = campaignName
|
||||
webhookReq.Data = capturedData
|
||||
if email != nil {
|
||||
webhookReq.Email = email.String()
|
||||
}
|
||||
}
|
||||
|
||||
// use context.Background() so a cancelled request context does not
|
||||
// abort the send - the goroutine outlives the originating request
|
||||
go func(wh *model.Webhook, req *WebhookRequest) {
|
||||
c.Logger.Debugw("sending webhook", "url", wh.URL.MustGet().String())
|
||||
_, err := c.WebhookService.Send(context.Background(), wh, req)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to send webhook", "error", err)
|
||||
}
|
||||
c.Logger.Debugw("sending webhook completed", "url", wh.URL.MustGet().String())
|
||||
}(webhook, &webhookReq)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleWebhook handles a single webhook (deprecated - kept for backward compatibility)
|
||||
func (c *Campaign) HandleWebhook(
|
||||
ctx context.Context,
|
||||
webhookID *uuid.UUID,
|
||||
@@ -3371,75 +3548,8 @@ func (c *Campaign) HandleWebhook(
|
||||
eventName string,
|
||||
capturedData map[string]interface{},
|
||||
) error {
|
||||
campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
email, err := c.RecipientRepository.GetEmailByID(ctx, recipientID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
webhook, err := c.WebhookRepository.GetByID(ctx, webhookID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check campaign webhook data level
|
||||
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check if this event should trigger webhook notification
|
||||
webhookEvents := 0
|
||||
if events, err := campaign.WebhookEvents.Get(); err == nil {
|
||||
webhookEvents = events
|
||||
}
|
||||
// 0 means all events (backward compatibility)
|
||||
// otherwise check if the event bit is set
|
||||
if !model.IsWebhookEventEnabled(webhookEvents, eventName) {
|
||||
// event not in selected events, skip webhook
|
||||
return nil
|
||||
}
|
||||
|
||||
// determine what data to send based on webhookIncludeData level
|
||||
dataLevel := model.WebhookDataLevelFull
|
||||
if level, err := campaign.WebhookIncludeData.Get(); err == nil {
|
||||
dataLevel = level
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
webhookReq := WebhookRequest{
|
||||
Time: &now,
|
||||
Event: eventName,
|
||||
}
|
||||
|
||||
// apply data level filters
|
||||
switch dataLevel {
|
||||
case model.WebhookDataLevelNone:
|
||||
// only time and event - no campaign name, no email, no data
|
||||
case model.WebhookDataLevelBasic:
|
||||
// include campaign name but no email or captured data
|
||||
webhookReq.CampaignName = campaignName
|
||||
case model.WebhookDataLevelFull:
|
||||
// include everything
|
||||
webhookReq.CampaignName = campaignName
|
||||
webhookReq.Data = capturedData
|
||||
if email != nil {
|
||||
webhookReq.Email = email.String()
|
||||
}
|
||||
}
|
||||
// the webhook is handles as a different go routine
|
||||
// so we don't block the campaign handling thread
|
||||
go func() {
|
||||
c.Logger.Debugw("sending webhook", "url", webhook.URL.MustGet().String())
|
||||
_, err := c.WebhookService.Send(ctx, webhook, &webhookReq)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to send webhook", "error", err)
|
||||
}
|
||||
c.Logger.Debugw("sending webhook completed", "url", webhook.URL.MustGet().String())
|
||||
}()
|
||||
return nil
|
||||
// for backward compatibility, delegate to HandleWebhooks
|
||||
return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData)
|
||||
}
|
||||
|
||||
// AnonymizeByID anonymizes a campaign including the events
|
||||
|
||||
@@ -280,6 +280,13 @@ func (w *Webhook) DeleteByID(
|
||||
w.Logger.Errorw("failed to remove web hook from campaigns", "error", err)
|
||||
return err
|
||||
}
|
||||
// remove junction table rows for this webhook so no campaign retains
|
||||
// a dangling reference in the new many-to-many format
|
||||
err = w.CampaignRepository.RemoveWebhookFromJunctionByWebhookID(ctx, id)
|
||||
if err != nil {
|
||||
w.Logger.Errorw("failed to remove webhook from campaign_webhooks junction", "error", err)
|
||||
return err
|
||||
}
|
||||
// delete
|
||||
err = w.WebhookRepository.DeleteByID(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -517,9 +517,7 @@ export class API {
|
||||
* @param {string[]} campaign.allowDenyIDs []uuid
|
||||
* @param {string} campaign.denyPageID uuid
|
||||
* @param {string} campaign.evasionPageID uuid
|
||||
* @param {string} campaign.webhookID uuid
|
||||
* @param {string} [campaign.webhookIncludeData]
|
||||
* @param {number} [campaign.webhookEvents]
|
||||
* @param {Array} [campaign.webhooks] array of webhook configs
|
||||
* @param {Array} [campaign.constraintWeekDays]
|
||||
* @param {string} [campaign.constraintStartTime]
|
||||
* @param {string} [campaign.constraintEndTime]
|
||||
@@ -546,9 +544,7 @@ export class API {
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhookID,
|
||||
webhookIncludeData,
|
||||
webhookEvents,
|
||||
webhooks,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -574,9 +570,7 @@ export class API {
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhookID,
|
||||
webhookIncludeData,
|
||||
webhookEvents,
|
||||
webhooks,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -605,9 +599,7 @@ export class API {
|
||||
* @param {string[]} campaign.allowDenyIDs []uuid
|
||||
* @param {string} campaign.denyPageID uuid
|
||||
* @param {string} campaign.evasionPageID uuid
|
||||
* @param {string} campaign.webhookID uuid
|
||||
* @param {string} [campaign.webhookIncludeData]
|
||||
* @param {number} [campaign.webhookEvents]
|
||||
* @param {Array} [campaign.webhooks] array of webhook configs
|
||||
* @param {Array} [campaign.constraintWeekDays]
|
||||
* @param {string} [campaign.constraintStartTime]
|
||||
* @param {string} [campaign.constraintEndTime]
|
||||
@@ -634,9 +626,7 @@ export class API {
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhookID,
|
||||
webhookIncludeData,
|
||||
webhookEvents,
|
||||
webhooks,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -661,9 +651,7 @@ export class API {
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhookID,
|
||||
webhookIncludeData,
|
||||
webhookEvents,
|
||||
webhooks,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
import ConditionalDisplay from '$lib/components/ConditionalDisplay.svelte';
|
||||
import ToolTip from '$lib/components/ToolTip.svelte';
|
||||
|
||||
let currentStep = 1;
|
||||
|
||||
@@ -200,9 +201,11 @@
|
||||
const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected
|
||||
|
||||
// convert array of event names to bitwise int
|
||||
// if all events are selected, return 0 to preserve the "all events" semantic
|
||||
const webhookEventsToBinary = (events) => {
|
||||
if (!events?.length) return 0; // 0 means all events
|
||||
return events.reduce((binary, event) => binary | (webhookEventToBit[event] || 0), 0);
|
||||
const binary = events.reduce((acc, event) => acc | (webhookEventToBit[event] || 0), 0);
|
||||
return binary === WEBHOOK_EVENT_ALL_BITS ? 0 : binary;
|
||||
};
|
||||
|
||||
// convert bitwise int to array of event names (internal values, not display names)
|
||||
@@ -384,9 +387,7 @@
|
||||
isTest: false,
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhookValue: null,
|
||||
webhookIncludeData: 'full',
|
||||
webhookEvents: [...webhookEventOptions], // all events selected by default
|
||||
webhooks: [], // array of {id, includeData, events}
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
@@ -770,9 +771,13 @@
|
||||
constraintWeekDays: weekDaysAvailableToBinary(formValues.constraintWeekDays),
|
||||
constraintStartTime: contraintStartTimeUTC,
|
||||
constraintEndTime: contraintEndTimeUTC,
|
||||
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
|
||||
webhookIncludeData: formValues.webhookIncludeData,
|
||||
webhookEvents: webhookEventsToBinary(formValues.webhookEvents),
|
||||
webhooks: formValues.webhooks
|
||||
.filter((wh) => wh.id !== null)
|
||||
.map((wh) => ({
|
||||
webhookID: wh.id,
|
||||
webhookIncludeData: wh.includeData,
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
});
|
||||
@@ -837,9 +842,13 @@
|
||||
allowDenyIDs: allowDenyIDs,
|
||||
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
|
||||
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
|
||||
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
|
||||
webhookIncludeData: formValues.webhookIncludeData,
|
||||
webhookEvents: webhookEventsToBinary(formValues.webhookEvents),
|
||||
webhooks: formValues.webhooks
|
||||
.filter((wh) => wh.id !== null)
|
||||
.map((wh) => ({
|
||||
webhookID: wh.id,
|
||||
webhookIncludeData: wh.includeData,
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
});
|
||||
@@ -943,7 +952,7 @@
|
||||
|
||||
const resetFormValues = () => {
|
||||
formValues = {
|
||||
name: '',
|
||||
name: null,
|
||||
sendStartAt: null,
|
||||
sendEndAt: null,
|
||||
scheduledStartAt: null,
|
||||
@@ -951,8 +960,8 @@
|
||||
closeAt: null,
|
||||
anonymizeAt: null,
|
||||
template: null,
|
||||
sortField: defaultSendField,
|
||||
sortOrder: defaultSendOrder,
|
||||
sortField: null,
|
||||
sortOrder: null,
|
||||
recipientGroups: [],
|
||||
allowDeny: [],
|
||||
denyPageValue: null,
|
||||
@@ -966,9 +975,7 @@
|
||||
isTest: false,
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhookValue: null,
|
||||
webhookIncludeData: 'full',
|
||||
webhookEvents: [...webhookEventOptions], // all events selected by default
|
||||
webhooks: [],
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
@@ -1076,9 +1083,28 @@
|
||||
isTest: campaign.isTest,
|
||||
obfuscate: campaign.obfuscate || false,
|
||||
template: templateMap.byKey(campaign.templateID),
|
||||
webhookValue: webhookMap.byKey(campaign.webhookID),
|
||||
webhookIncludeData: campaign.webhookIncludeData || 'full',
|
||||
webhookEvents: webhookEventsFromBinary(campaign.webhookEvents || 0)
|
||||
webhooks: (() => {
|
||||
// handle new webhooks array format
|
||||
if (campaign.webhooks && campaign.webhooks.length > 0) {
|
||||
return campaign.webhooks.map((wh) => ({
|
||||
id: wh.webhookID,
|
||||
includeData: wh.webhookIncludeData ?? 'full',
|
||||
events: webhookEventsFromBinary(wh.webhookEvents ?? 0)
|
||||
}));
|
||||
}
|
||||
// handle old single webhook format (backward compatibility)
|
||||
if (campaign.webhookID) {
|
||||
return [
|
||||
{
|
||||
id: campaign.webhookID,
|
||||
includeData: campaign.webhookIncludeData ?? 'full',
|
||||
events: webhookEventsFromBinary(campaign.webhookEvents ?? 0)
|
||||
}
|
||||
];
|
||||
}
|
||||
// no webhooks
|
||||
return [];
|
||||
})()
|
||||
};
|
||||
|
||||
if (copyMode) {
|
||||
@@ -1122,6 +1148,7 @@
|
||||
|
||||
showAdvancedOptionsStep4 = !!(
|
||||
campaign.webhookID ||
|
||||
campaign.webhooks?.length ||
|
||||
campaign.denyPage ||
|
||||
campaign.evasionPage ||
|
||||
campaign.allowDeny?.length ||
|
||||
@@ -1250,6 +1277,37 @@
|
||||
element.reportValidity();
|
||||
};
|
||||
|
||||
// webhook helper functions
|
||||
const addWebhook = () => {
|
||||
formValues.webhooks = [
|
||||
...formValues.webhooks,
|
||||
{
|
||||
id: null,
|
||||
includeData: 'full',
|
||||
events: [...webhookEventOptions] // all events by default
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const removeWebhook = (index) => {
|
||||
formValues.webhooks = formValues.webhooks.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const toggleWebhookEvent = (webhookIndex, eventValue) => {
|
||||
const webhook = formValues.webhooks[webhookIndex];
|
||||
const isSelected = webhook.events.includes(eventValue);
|
||||
|
||||
if (isSelected) {
|
||||
// prevent unselecting the last item
|
||||
if (webhook.events.length > 1) {
|
||||
webhook.events = webhook.events.filter((e) => e !== eventValue);
|
||||
}
|
||||
} else {
|
||||
webhook.events = [...webhook.events, eventValue];
|
||||
}
|
||||
formValues.webhooks = [...formValues.webhooks]; // trigger reactivity
|
||||
};
|
||||
|
||||
// check if user is in the correct context for campaign actions
|
||||
const isContextMismatch = (campaign) => {
|
||||
const context = appStateService.getContext();
|
||||
@@ -1884,69 +1942,105 @@
|
||||
{/if}
|
||||
|
||||
{#if showAdvancedOptionsStep4}
|
||||
<div class="mb-6">
|
||||
<TextFieldSelect
|
||||
id="webhook"
|
||||
bind:value={formValues.webhookValue}
|
||||
optional
|
||||
options={Array.from(webhookMap.values())}>Webhook</TextFieldSelect
|
||||
>
|
||||
</div>
|
||||
{#if formValues.webhookValue}
|
||||
<div class="mb-6">
|
||||
<SelectSquare
|
||||
bind:value={formValues.webhookIncludeData}
|
||||
options={webhookDataLevelOptions}
|
||||
label="Webhook Data Level"
|
||||
toolTipText="Control what campaign and recipient information is sent to the webhook endpoint"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6 max-w-xl">
|
||||
<label class="flex flex-col">
|
||||
<div class="flex items-center py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400">Webhook Events</p>
|
||||
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-xs"
|
||||
>({formValues.webhookEvents.length === webhookEventOptions.length
|
||||
? 'All'
|
||||
: formValues.webhookEvents.length} selected)</span
|
||||
>
|
||||
<div class="mb-6 pt-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400">Webhooks</p>
|
||||
<ToolTip>
|
||||
Configure multiple webhooks to receive campaign event notifications. Each
|
||||
webhook can have its own data level and event filters.
|
||||
</ToolTip>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
|
||||
>
|
||||
<p
|
||||
class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200"
|
||||
>
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 max-w-lg">
|
||||
{#each formValues.webhooks as webhook, index}
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800/50 dark:to-gray-800/30 rounded-lg border border-gray-300 dark:border-gray-600/50 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextFieldSelect
|
||||
id="webhook-{index}"
|
||||
bind:value={webhook.id}
|
||||
optional
|
||||
options={webhookMap
|
||||
.keys()
|
||||
.map((k) => ({ value: k, label: webhookMap.byKey(k) }))}
|
||||
>
|
||||
Endpoint
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="flex items-end pb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700/80 rounded-md transition-colors duration-200"
|
||||
on:click={() => removeWebhook(index)}
|
||||
title="Remove this webhook"
|
||||
aria-label="Remove webhook"
|
||||
>
|
||||
<img class="w-4 flex-shrink-0" src="/delete2.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectSquare
|
||||
bind:value={webhook.includeData}
|
||||
options={webhookDataLevelOptions}
|
||||
label="Data Level"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Events
|
||||
</p>
|
||||
<span
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{webhook.events.length === webhookEventOptions.length
|
||||
? 'All'
|
||||
: webhook.events.length} / {webhookEventOptions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-1.5 max-h-28 overflow-y-auto p-2 bg-white/50 dark:bg-gray-900/30 rounded border border-gray-200 dark:border-gray-700/50"
|
||||
>
|
||||
{#each webhookEventDisplayOptions as eventOption}
|
||||
{@const isSelected = webhook.events.includes(eventOption.value)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleWebhookEvent(index, eventOption.value)}
|
||||
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-200 {isSelected
|
||||
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
|
||||
>
|
||||
{eventOption.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap gap-2 max-h-48 overflow-y-auto">
|
||||
{#each webhookEventDisplayOptions as eventOption}
|
||||
{@const isSelected = formValues.webhookEvents.includes(eventOption.value)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (isSelected) {
|
||||
// prevent unselecting the last item
|
||||
if (formValues.webhookEvents.length > 1) {
|
||||
formValues.webhookEvents = formValues.webhookEvents.filter(
|
||||
(e) => e !== eventOption.value
|
||||
);
|
||||
}
|
||||
} else {
|
||||
formValues.webhookEvents = [
|
||||
...formValues.webhookEvents,
|
||||
eventOption.value
|
||||
];
|
||||
}
|
||||
}}
|
||||
class="flex flex-row items-center px-3 py-2 rounded-md text-xs transition-colors duration-200 flex-shrink-0 {isSelected
|
||||
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-2 border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border-2 border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
|
||||
style="max-width: 280px;"
|
||||
>
|
||||
<span class="truncate block" style="max-width: 250px;">
|
||||
{eventOption.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-semibold rounded-md transition-all duration-200"
|
||||
on:click={addWebhook}
|
||||
>
|
||||
+ Add Webhook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
<div class="mb-6">
|
||||
@@ -2349,20 +2443,25 @@
|
||||
>
|
||||
-->
|
||||
|
||||
{#if formValues.webhookValue}
|
||||
<span class="text-grayblue-dark font-medium">Webhook:</span>
|
||||
<span class="text-pc-darkblue dark:text-white">{formValues.webhookValue}</span
|
||||
>
|
||||
<span class="text-grayblue-dark font-medium">Data Level:</span>
|
||||
<span class="text-pc-darkblue dark:text-white capitalize"
|
||||
>{formValues.webhookIncludeData}</span
|
||||
>
|
||||
<span class="text-grayblue-dark font-medium">Webhook Events:</span>
|
||||
<span class="text-pc-darkblue dark:text-white">
|
||||
{formValues.webhookEvents.length === webhookEventOptions.length
|
||||
? 'All Events'
|
||||
: formValues.webhookEvents.length + ' selected'}
|
||||
</span>
|
||||
{#if formValues.webhooks.length > 0}
|
||||
<span class="text-grayblue-dark font-medium">Webhooks:</span>
|
||||
<div class="text-pc-darkblue dark:text-white space-y-2">
|
||||
{#each formValues.webhooks as webhook, index}
|
||||
<div class="border-l-2 border-blue-400 pl-3 py-1">
|
||||
<div class="font-medium">
|
||||
{webhookMap.byKey(webhook.id) || 'Not selected'}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Data Level: <span class="capitalize">{webhook.includeData}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Events: {webhook.events.length === webhookEventOptions.length
|
||||
? 'All Events'
|
||||
: webhook.events.length + ' selected'}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formValues.denyPageValue}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
|
||||
allowDenyIDs: [],
|
||||
webhookID: null,
|
||||
webhooks: [],
|
||||
// groups by name, must be mapped to IDs before sending to the server
|
||||
recipientGroups: [],
|
||||
events: [],
|
||||
@@ -81,6 +82,8 @@
|
||||
companyID: null,
|
||||
company: null
|
||||
};
|
||||
// map of webhook id -> webhook name for display
|
||||
let webhookMap = new BiMap({});
|
||||
let allowedFilter = null;
|
||||
let campaignRecipients = [];
|
||||
let campaignRecipientsHasNextPage = false;
|
||||
@@ -247,9 +250,20 @@
|
||||
campaign.denyPage = t.denyPage;
|
||||
campaign.evasionPage = t.evasionPage;
|
||||
campaign.webhookID = t.webhookID;
|
||||
campaign.webhooks = t.webhooks ?? [];
|
||||
campaign.companyID = t.companyID;
|
||||
campaign.company = t.company;
|
||||
|
||||
// load webhook names for display
|
||||
try {
|
||||
const allWebhooks = await fetchAllRows((options) => {
|
||||
return api.webhook.getAll(options, campaign.companyID ?? null);
|
||||
});
|
||||
webhookMap = BiMap.FromArrayOfObjects(allWebhooks);
|
||||
} catch (e) {
|
||||
console.error('failed to load webhooks for display', e);
|
||||
}
|
||||
|
||||
// if company exists but name is missing, fetch it
|
||||
if (campaign.companyID && !campaign.company?.name) {
|
||||
try {
|
||||
@@ -1443,9 +1457,25 @@
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Webhook:</span>
|
||||
<span class="text-pc-darkblue dark:text-white">
|
||||
{campaign.webhookID ? 'Yes' : 'None'}
|
||||
</span>
|
||||
<div class="text-right">
|
||||
{#if campaign.webhooks?.length}
|
||||
{#each campaign.webhooks as wh, i}
|
||||
<a
|
||||
class="text-cta-blue dark:text-blue-400 hover:underline"
|
||||
href="/webhook?edit={wh.webhookID}"
|
||||
target="_blank">{webhookMap.byKey(wh.webhookID) || wh.webhookID}</a
|
||||
>{#if i < campaign.webhooks.length - 1}, {/if}
|
||||
{/each}
|
||||
{:else if campaign.webhookID}
|
||||
<a
|
||||
class="text-cta-blue dark:text-blue-400 hover:underline"
|
||||
href="/webhook?edit={campaign.webhookID}"
|
||||
target="_blank">{webhookMap.byKey(campaign.webhookID) || 'Yes'}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="text-pc-darkblue dark:text-white">None</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
@@ -79,6 +80,13 @@
|
||||
refreshWebhooks();
|
||||
tableURLParams.onChange(refreshWebhooks);
|
||||
|
||||
(async () => {
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
if (editID) {
|
||||
await openEditModal(editID);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
tableURLParams.unsubscribe();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user