diff --git a/backend/app/server.go b/backend/app/server.go
index b50d337..5e2161f 100644
--- a/backend/app/server.go
+++ b/backend/app/server.go
@@ -663,6 +663,117 @@ func (s *Server) handlerNotFound(c *gin.Context) {
)
}
+// pageVisitEventName maps a page type to the campaign event recorded for a visit.
+func pageVisitEventName(pageType string) string {
+ switch pageType {
+ case data.PAGE_TYPE_EVASION:
+ return data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED
+ case data.PAGE_TYPE_BEFORE:
+ return data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
+ case data.PAGE_TYPE_AFTER:
+ return data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
+ default:
+ return data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
+ }
+}
+
+// trainingMilestoneEventName maps a page type to the training milestone recorded
+// on top of the raw page visit for a training campaign. A lesson visit
+// (before/landing) is training_started, the after page is training_completed. An
+// empty string means the page type has no milestone.
+func trainingMilestoneEventName(pageType string) string {
+ switch pageType {
+ case data.PAGE_TYPE_BEFORE, data.PAGE_TYPE_LANDING:
+ return data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED
+ case data.PAGE_TYPE_AFTER:
+ return data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED
+ default:
+ return ""
+ }
+}
+
+// emitTrainingMilestoneIfNeeded records a training_started or training_completed
+// event for a training campaign, at most once per recipient. The raw page visit
+// event is still recorded separately, so this adds a deduplicated milestone on
+// top of it rather than replacing it.
+func (s *Server) emitTrainingMilestoneIfNeeded(
+ c *gin.Context,
+ campaign *model.Campaign,
+ campaignRecipient *model.CampaignRecipient,
+ campaignRecipientIDPtr *uuid.UUID,
+ campaignID uuid.UUID,
+ recipientID uuid.UUID,
+ pageType string,
+) error {
+ isTraining := false
+ if v, err := campaign.IsTraining.Get(); err == nil {
+ isTraining = v
+ }
+ if !isTraining {
+ return nil
+ }
+ // an anonymous campaign stores its events without a recipient, so a milestone
+ // cannot be deduplicated per recipient and would be recorded again on every
+ // visit
+ if campaign.IsAnonymous.MustGet() {
+ return nil
+ }
+ milestoneName := trainingMilestoneEventName(pageType)
+ if milestoneName == "" {
+ return nil
+ }
+ milestoneEventID := cache.EventIDByName[milestoneName]
+ if milestoneEventID == nil {
+ return nil
+ }
+ // a milestone is recorded once per recipient per campaign
+ has, err := s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID)
+ if err != nil {
+ s.logger.Errorw("failed to check for existing training milestone event", "error", err)
+ // continue and attempt to create it
+ }
+ if has {
+ return nil
+ }
+ eventID := uuid.New()
+ clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies))
+ userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
+ event := &model.CampaignEvent{
+ ID: &eventID,
+ CampaignID: &campaignID,
+ RecipientID: &recipientID,
+ IP: clientIP,
+ UserAgent: userAgent,
+ EventID: milestoneEventID,
+ Data: vo.NewEmptyOptionalString1MB(),
+ Metadata: model.ExtractCampaignEventMetadata(c, campaign),
+ }
+ if err := s.repositories.Campaign.SaveEvent(c, event); err != nil {
+ return fmt.Errorf("failed to save training milestone event: %s", err)
+ }
+ // let the milestone become the notable event so completion outranks the raw
+ // page visit for this recipient
+ if campaignRecipient != nil && campaignRecipientIDPtr != nil {
+ currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
+ if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, milestoneEventID) {
+ campaignRecipient.NotableEventID.Set(*milestoneEventID)
+ if err := s.repositories.CampaignRecipient.UpdateByID(c, campaignRecipientIDPtr, campaignRecipient); err != nil {
+ s.logger.Errorw("failed to update notable event for training milestone", "error", err)
+ }
+ }
+ }
+ if err := s.services.Campaign.HandleWebhooks(
+ context.TODO(),
+ &campaignID,
+ &recipientID,
+ milestoneName,
+ nil,
+ ); err != nil {
+ return fmt.Errorf("failed to handle webhooks for training milestone: %s", err)
+ }
+ return nil
+}
+
// checkAndServePhishingPage serves a phishing page
// returns a bool if the request was for a phishing page
// and an error if there was an error
@@ -1407,19 +1518,7 @@ func (s *Server) checkAndServePhishingPage(
// save the event of Proxy page being accessed
visitEventID := uuid.New()
- eventName := ""
- switch currentPageType {
- case data.PAGE_TYPE_EVASION:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED
- case data.PAGE_TYPE_BEFORE:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
- case data.PAGE_TYPE_LANDING:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
- case data.PAGE_TYPE_AFTER:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
- default:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
- }
+ eventName := pageVisitEventName(currentPageType)
eventID := cache.EventIDByName[eventName]
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies))
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
@@ -1505,6 +1604,19 @@ func (s *Server) checkAndServePhishingPage(
}
}
+ // record the training milestone once, on top of the raw page visit
+ if err := s.emitTrainingMilestoneIfNeeded(
+ c,
+ campaign,
+ campaignRecipient,
+ campaignRecipientIDPtr,
+ campaignID,
+ recipientID,
+ currentPageType,
+ ); err != nil {
+ return true, errs.Wrap(err)
+ }
+
// validate phishing domain format
if strings.Contains(phishingDomain, "://") || strings.Contains(phishingDomain, "/") {
return true, fmt.Errorf("invalid phishing domain format: %s", phishingDomain)
@@ -1769,17 +1881,7 @@ func (s *Server) checkAndServePhishingPage(
}
// save the event of page has been visited
- eventName := ""
- switch currentPageType {
- case data.PAGE_TYPE_EVASION:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED
- case data.PAGE_TYPE_BEFORE:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
- case data.PAGE_TYPE_LANDING:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
- case data.PAGE_TYPE_AFTER:
- eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
- }
+ eventName := pageVisitEventName(currentPageType)
campaignEventID := cache.EventIDByName[eventName]
eventID := uuid.New()
@@ -1858,6 +1960,19 @@ func (s *Server) checkAndServePhishingPage(
}
}
+ // record the training milestone once, on top of the raw page visit
+ if err := s.emitTrainingMilestoneIfNeeded(
+ c,
+ campaign,
+ campaignRecipient,
+ campaignRecipientIDPtr,
+ campaignID,
+ recipientID,
+ currentPageType,
+ ); err != nil {
+ return true, errs.Wrap(err)
+ }
+
return true, nil
}
diff --git a/backend/cache/local.go b/backend/cache/local.go
index 85d8d7e..5804931 100644
--- a/backend/cache/local.go
+++ b/backend/cache/local.go
@@ -38,6 +38,10 @@ func IsUpdateAvailable() bool {
var CampaignEventPriority = map[string]int{
// campaign recipient events
data.EVENT_CAMPAIGN_RECIPIENT_INFO: 5,
+ // training milestones outrank the raw page visits they accompany so a training
+ // campaign shows the milestone as the recipient's notable event
+ data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED: 66,
+ data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED: 62,
data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90,
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
diff --git a/backend/data/events.go b/backend/data/events.go
index 8bd443a..dfe080b 100644
--- a/backend/data/events.go
+++ b/backend/data/events.go
@@ -20,6 +20,11 @@ const (
EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported"
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
EVENT_CAMPAIGN_RECIPIENT_INFO = "campaign_recipient_info"
+
+ // training campaign events, recorded on top of the page visit events when the
+ // campaign is built from a training template
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED = "campaign_recipient_training_started"
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED = "campaign_recipient_training_completed"
)
var Events = []string{
@@ -43,6 +48,8 @@ var Events = []string{
EVENT_CAMPAIGN_RECIPIENT_REPORTED,
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
EVENT_CAMPAIGN_RECIPIENT_INFO,
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED,
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED,
}
// webhook event bit flags for storing selected events as int
@@ -56,12 +63,14 @@ const (
WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED = 1 << 5 // 32
WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = 1 << 6 // 64
WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_PAGE_VISITED = 1 << 7 // 128
- WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = 1 << 8 // 256
- WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED = 1 << 9 // 512
+ WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = 1 << 8 // 256
+ WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED = 1 << 9 // 512
+ WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_TRAINING_STARTED = 1 << 10 // 1024
+ WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED = 1 << 11 // 2048
)
// WEBHOOK_EVENT_ALL_BITS represents all events selected
-const WEBHOOK_EVENT_ALL_BITS = 1023 // 2^10 - 1
+const WEBHOOK_EVENT_ALL_BITS = 4095 // 2^12 - 1
// map event names to their bit positions
var WebhookEventToBit = map[string]int{
@@ -75,4 +84,6 @@ var WebhookEventToBit = map[string]int{
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_TRAINING_STARTED,
+ EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED,
}
diff --git a/backend/database/campaign.go b/backend/database/campaign.go
index 29599c5..1b5349c 100644
--- a/backend/database/campaign.go
+++ b/backend/database/campaign.go
@@ -72,6 +72,10 @@ type Campaign struct {
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
LureCodeLength int `gorm:"not null;default:12"`
+ // IsTraining marks the campaign as an awareness training campaign. Snapshotted
+ // from the template at create time so stats and risk queries filter on it here.
+ IsTraining bool `gorm:"not null;default:false;index"`
+
// has one
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
CampaignTemplate *CampaignTemplate
diff --git a/backend/database/campaignStats.go b/backend/database/campaignStats.go
index 1e2aef1..7af743c 100644
--- a/backend/database/campaignStats.go
+++ b/backend/database/campaignStats.go
@@ -37,9 +37,16 @@ type CampaignStats struct {
DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions
Reported int `gorm:"not null;default:0" json:"reported"` // Reported phishing
+ // Training funnel, populated for training campaigns only
+ TrainingStarted int `gorm:"not null;default:0" json:"trainingStarted"`
+ TrainingCompleted int `gorm:"not null;default:0" json:"trainingCompleted"`
+
// Campaign metadata
TemplateName string `gorm:"" json:"templateName"`
CampaignType string `gorm:"" json:"campaignType"` // 'scheduled', 'self-managed'
+ // IsTraining marks the snapshot as an awareness training campaign so phishing
+ // trend charts can exclude it from failure rate calculations.
+ IsTraining bool `gorm:"not null;default:false" json:"isTraining"`
}
func (CampaignStats) TableName() string {
diff --git a/backend/database/campaignTemplate.go b/backend/database/campaignTemplate.go
index 98f23ac..0f135b2 100644
--- a/backend/database/campaignTemplate.go
+++ b/backend/database/campaignTemplate.go
@@ -36,6 +36,10 @@ type CampaignTemplate struct {
// data such as domainID, landingPage and etc to be used in a campaign
IsUsable bool `gorm:"not null;default:false;index"`
+ // IsTraining marks the template as an awareness training template. Campaigns
+ // built from it emit training events instead of phishing page visit events.
+ IsTraining bool `gorm:"not null;default:false;index"`
+
// has-a
LandingPageID *uuid.UUID `gorm:"type:uuid;index;"`
LandingPage *Page `gorm:"references:LandingPage;foreignKey:LandingPageID;references:ID;"`
diff --git a/backend/database/campaignWebhook.go b/backend/database/campaignWebhook.go
index 0819b61..1ad9280 100644
--- a/backend/database/campaignWebhook.go
+++ b/backend/database/campaignWebhook.go
@@ -18,7 +18,7 @@ type CampaignWebhook struct {
// values: "none", "basic", "full"
WebhookIncludeData string `gorm:"not null;default:'full'"`
- // webhookevents is a binary format storing selected events as bits (10 events)
+ // webhookevents is a binary format storing selected events as bits (12 events)
// 0 = all events (default, backward compatible)
// non-zero = only selected events trigger webhooks
// bit 0 (1): campaign_closed
@@ -31,6 +31,8 @@ type CampaignWebhook struct {
// bit 7 (128): campaign_recipient_page_visited
// bit 8 (256): campaign_recipient_after_page_visited
// bit 9 (512): campaign_recipient_deny_page_visited
+ // bit 10 (1024): campaign_recipient_training_started
+ // bit 11 (2048): campaign_recipient_training_completed
WebhookEvents int `gorm:"not null;default:0"`
}
diff --git a/backend/database/reportTemplate.go b/backend/database/reportTemplate.go
index 579ee73..0759832 100644
--- a/backend/database/reportTemplate.go
+++ b/backend/database/reportTemplate.go
@@ -16,15 +16,28 @@ type ReportTemplate struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
- CompanyID *uuid.UUID `gorm:"uniqueIndex;type:uuid"`
+ CompanyID *uuid.UUID `gorm:"type:uuid"`
Content string `gorm:"not null;type:text"`
+ // IsTraining selects the report used for awareness training campaigns, so a
+ // company can hold a separate phishing and training report template. Uniqueness
+ // is enforced per kind by the indexes in Migrate.
+ IsTraining bool `gorm:"not null;default:false"`
+
Company *Company
}
func (e *ReportTemplate) Migrate(db *gorm.DB) error {
- // enforce at most one global template (company_id IS NULL)
- idx := `CREATE UNIQUE INDEX IF NOT EXISTS idx_report_templates_null_company_id ON report_templates ((company_id IS NULL)) WHERE (company_id IS NULL)`
+ // drop the legacy indexes that enforced one template per company and one global,
+ // replaced by the per kind indexes below
+ db.Exec(`DROP INDEX IF EXISTS idx_report_templates_company_id`)
+ db.Exec(`DROP INDEX IF EXISTS idx_report_templates_null_company_id`)
+ // one template per company per kind (the global case is covered separately)
+ if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_report_templates_company_training ON report_templates(company_id, is_training) WHERE company_id IS NOT NULL`).Error; err != nil {
+ return err
+ }
+ // enforce at most one global template per kind (company_id IS NULL)
+ idx := `CREATE UNIQUE INDEX IF NOT EXISTS idx_report_templates_null_company_training ON report_templates(is_training) WHERE company_id IS NULL`
return db.Exec(idx).Error
}
diff --git a/backend/embedded/default_training_report.html b/backend/embedded/default_training_report.html
new file mode 100644
index 0000000..a2bcc25
--- /dev/null
+++ b/backend/embedded/default_training_report.html
@@ -0,0 +1,513 @@
+
+
+
+
+
+{{.CampaignName}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Confidential
+
+
+
+
Awareness Training Report
+
{{.CampaignName}}
+
Training Results
+
+
+
+
+
+ {{if .CompanyName}}
+ Company {{.CompanyName}}
+ {{end}}
+ Date {{.ReportDate}}
+ {{if .CampaignStartDate}}
+ Started {{.CampaignStartDate}}
+ {{end}}
+ {{if .CampaignClosedAt}}
+ Closed {{.CampaignClosedAt}}
+ {{end}}
+
+
+
+
+
+
+
+
+
+
Training results
+
+
+
Recipients
+
{{.TotalTargets}}
+
total assigned
+
+
+
Emails Sent
+
{{.EmailsSent}}
+
{{printf "%.0f" .SentRate}}% of recipients
+
+
+
Emails Read
+
{{.EmailsOpened}}
+
{{printf "%.0f" .OpenRate}}% of recipients
+
+
+
+
+
Training Started
+
{{.TrainingStarted}}
+
{{.TrainingStartedPercent}}% of recipients
+
+
+
Training Completed
+
{{.TrainingCompleted}}
+
{{.TrainingCompletedPercent}}% of recipients
+
+
+
Completion Rate
+
{{.CompletedOfStarted}}%
+
of those who started
+
+
+
+
Outcome
+
+
+
+
+
+ {{if .OpenRate}}
+
+ {{end}}
+ {{printf "%.0f" .OpenRate}}%
+ of recipients
+
+
Emails Read
+
{{.EmailsOpened}} recipients
+
+
+
+
+
+ {{if .TrainingStartedRate}}
+
+ {{end}}
+ {{.TrainingStartedPercent}}%
+ of recipients
+
+
Training Started
+
{{.TrainingStarted}} recipients
+
+
+
+
+
+ {{if .TrainingCompletedRate}}
+
+ {{end}}
+ {{.TrainingCompletedPercent}}%
+ of recipients
+
+
Training Completed
+
{{.TrainingCompleted}} recipients
+
+
+
+
+
Conversion
+
+
+
{{.OpenedOfSent}}%
+
of those who receivedread the email
+
+
+
{{.StartedOfOpened}}%
+
of those who readstarted the training
+
+
+
{{.CompletedOfStarted}}%
+
of those who startedcompleted the training
+
+
+
+
+
+
+{{if .Recipients}}
+
+
+
+
+
+
+
+ Name
+ Email
+ Started
+ Completed
+ Status
+
+
+
+ {{range .Recipients}}
+
+ {{.FirstName}} {{.LastName}}
+ {{.Email}}
+ {{if .TrainingStarted}}Yes {{else}}- {{end}}
+ {{if .TrainingCompleted}}Yes {{else}}- {{end}}
+
+ {{if .TrainingCompleted}}Completed
+ {{else if .TrainingStarted}}In Progress
+ {{else}}Not Started {{end}}
+
+
+ {{end}}
+
+
+
+
+
+{{end}}
+
+
+
diff --git a/backend/embedded/files.go b/backend/embedded/files.go
index 17b2986..c9c408f 100644
--- a/backend/embedded/files.go
+++ b/backend/embedded/files.go
@@ -22,3 +22,6 @@ var SigningKey2 []byte
//go:embed default_report.html
var DefaultReportHTML string
+
+//go:embed default_training_report.html
+var DefaultTrainingReportHTML string
diff --git a/backend/model/campaign.go b/backend/model/campaign.go
index 12c95c9..85d17b1 100644
--- a/backend/model/campaign.go
+++ b/backend/model/campaign.go
@@ -49,6 +49,8 @@ type Campaign struct {
IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"`
IsTest nullable.Nullable[bool] `json:"isTest"`
Obfuscate nullable.Nullable[bool] `json:"obfuscate"`
+ // IsTraining is snapshotted from the template; read-only in campaign requests.
+ IsTraining nullable.Nullable[bool] `json:"isTraining"`
// 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"`
@@ -474,6 +476,12 @@ func (c *Campaign) ToDBMap() map[string]any {
m["data_anonymize_at"] = utils.RFC3339UTC(v)
}
}
+ if c.IsTraining.IsSpecified() {
+ m["is_training"] = false
+ if v, err := c.IsTraining.Get(); err == nil {
+ m["is_training"] = v
+ }
+ }
if c.SaveSubmittedData.IsSpecified() {
m["save_submitted_data"] = false
if v, err := c.SaveSubmittedData.Get(); err == nil {
diff --git a/backend/model/campaignResultView.go b/backend/model/campaignResultView.go
index 3a4d291..0eab377 100644
--- a/backend/model/campaignResultView.go
+++ b/backend/model/campaignResultView.go
@@ -7,4 +7,7 @@ type CampaignResultView struct {
WebsiteLoaded int64 `json:"clickedLink"`
SubmittedData int64 `json:"submittedData"`
Reported int64 `json:"reported"`
+ // training campaign funnel, zero for phishing campaigns
+ TrainingStarted int64 `json:"trainingStarted"`
+ TrainingCompleted int64 `json:"trainingCompleted"`
}
diff --git a/backend/model/campaignTemplate.go b/backend/model/campaignTemplate.go
index e6b26fd..c5d44cb 100644
--- a/backend/model/campaignTemplate.go
+++ b/backend/model/campaignTemplate.go
@@ -76,6 +76,8 @@ type CampaignTemplate struct {
Company *Company `json:"company"`
IsUsable nullable.Nullable[bool] `json:"isUsable"`
+
+ IsTraining nullable.Nullable[bool] `json:"isTraining"`
}
// Validate checks if the campaign template has a valid state
@@ -149,6 +151,15 @@ func (c *CampaignTemplate) Validate() error {
errors.New("after landing page cannot be both a page and a proxy"),
)
}
+ // the after landing page is what marks a training as completed, without it
+ // there is nothing to record completion on
+ if v, err := c.IsTraining.Get(); err == nil && v {
+ if errAfterPage != nil && errAfterProxy != nil {
+ return errs.NewValidationError(
+ errors.New("after landing page is required for awareness training"),
+ )
+ }
+ }
// validate that smtp configuration and api sender are mutually exclusive
_, errSMTP := c.SMTPConfigurationID.Get()
@@ -305,6 +316,13 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
}
+ if c.IsTraining.IsSpecified() {
+ m["is_training"] = false
+ if v, err := c.IsTraining.Get(); err == nil {
+ m["is_training"] = v
+ }
+ }
+
_, errDomain := c.DomainID.Get()
_, errSMTP := c.SMTPConfigurationID.Get()
_, errAPISender := c.APISenderID.Get()
diff --git a/backend/model/campaignWebhook.go b/backend/model/campaignWebhook.go
index 3e4d2b6..46df2ab 100644
--- a/backend/model/campaignWebhook.go
+++ b/backend/model/campaignWebhook.go
@@ -38,7 +38,7 @@ func (cw *CampaignWebhook) Validate() error {
// 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)
+ // check if any invalid bits are set, the valid ones are the mapped events
maxValidBits := 0
for _, bit := range data.WebhookEventToBit {
maxValidBits |= bit
diff --git a/backend/model/recipientCampaignStatsView.go b/backend/model/recipientCampaignStatsView.go
index 048edee..a4772af 100644
--- a/backend/model/recipientCampaignStatsView.go
+++ b/backend/model/recipientCampaignStatsView.go
@@ -8,4 +8,7 @@ type RecipientCampaignStatsView struct {
CampaignsReported int64 `json:"campaignsReported"`
RepeatLinkClicks int64 `json:"repeatLinkClicks"`
RepeatSubmissions int64 `json:"repeatSubmissions"`
+ // training compliance, counted from training campaigns only
+ TrainingsAssigned int64 `json:"trainingsAssigned"`
+ TrainingsCompleted int64 `json:"trainingsCompleted"`
}
diff --git a/backend/model/reportTemplate.go b/backend/model/reportTemplate.go
index 63cba0b..ae8ed31 100644
--- a/backend/model/reportTemplate.go
+++ b/backend/model/reportTemplate.go
@@ -14,8 +14,9 @@ type ReportTemplate struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
- CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
- Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
+ CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
+ Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
+ IsTraining nullable.Nullable[bool] `json:"isTraining"`
Company *Company `json:"-"`
}
@@ -44,6 +45,11 @@ func (r *ReportTemplate) ToDBMap() map[string]any {
m["company_id"] = r.CompanyID.MustGet()
}
}
+ if r.IsTraining.IsSpecified() {
+ if v, err := r.IsTraining.Get(); err == nil {
+ m["is_training"] = v
+ }
+ }
return m
}
@@ -85,18 +91,32 @@ type ReportData struct {
ClickedOfOpened string // ResultClicked / EmailsOpened
SubmittedOfClicked string // ResultSubmitted / ResultClicked
+ // Awareness training funnel — populated for training campaigns, zero otherwise.
+ // The training report template renders these in place of the phishing outcomes.
+ IsTraining bool
+ TrainingStarted int64
+ TrainingCompleted int64
+ TrainingStartedPercent string // of recipients
+ TrainingCompletedPercent string // of recipients
+ TrainingStartedRate float64 // of recipients
+ TrainingCompletedRate float64 // of recipients
+ StartedOfOpened string // TrainingStarted / EmailsOpened
+ CompletedOfStarted string // TrainingCompleted / TrainingStarted
+
// Per-recipient detail — empty for anonymous or anonymized campaigns
Recipients []ReportRecipient
}
// ReportRecipient holds per-recipient result data for the recipient detail table
type ReportRecipient struct {
- FirstName string
- LastName string
- Email string
- Department string
- Position string
- ClickedLink bool
- SubmittedData bool
- Reported bool
+ FirstName string
+ LastName string
+ Email string
+ Department string
+ Position string
+ ClickedLink bool
+ SubmittedData bool
+ Reported bool
+ TrainingStarted bool
+ TrainingCompleted bool
}
diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go
index 08a1764..bdfdcf1 100644
--- a/backend/repository/campaign.go
+++ b/backend/repository/campaign.go
@@ -1052,6 +1052,75 @@ func (r *Campaign) GetResultStats(
return nil, res.Error
}
+ // the training funnel exists for training campaigns only, a phishing campaign
+ // emits no training events so the counts below would scan for rows that
+ // cannot be there
+ var isTraining bool
+ res = r.DB.Raw(`
+ SELECT is_training
+ FROM campaigns
+ WHERE id = ?
+`,
+ campaignID,
+ ).Scan(&isTraining)
+
+ if res.Error != nil {
+ return nil, res.Error
+ }
+ if !isTraining {
+ return stats, nil
+ }
+
+ res = r.DB.Raw(`
+ SELECT COUNT(*) FROM (
+ SELECT DISTINCT recipient_id
+ FROM campaign_events
+ WHERE campaign_id = ?
+ AND event_id = ?
+ AND recipient_id IS NOT NULL
+ UNION
+ SELECT DISTINCT anonymized_id
+ FROM campaign_events
+ WHERE campaign_id = ?
+ AND event_id = ?
+ AND anonymized_id IS NOT NULL
+ ) as unique_ids
+`,
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED],
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED],
+ ).Scan(&stats.TrainingStarted)
+
+ if res.Error != nil {
+ return nil, res.Error
+ }
+
+ res = r.DB.Raw(`
+ SELECT COUNT(*) FROM (
+ SELECT DISTINCT recipient_id
+ FROM campaign_events
+ WHERE campaign_id = ?
+ AND event_id = ?
+ AND recipient_id IS NOT NULL
+ UNION
+ SELECT DISTINCT anonymized_id
+ FROM campaign_events
+ WHERE campaign_id = ?
+ AND event_id = ?
+ AND anonymized_id IS NOT NULL
+ ) as unique_ids
+`,
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED],
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED],
+ ).Scan(&stats.TrainingCompleted)
+
+ if res.Error != nil {
+ return nil, res.Error
+ }
+
return stats, nil
}
@@ -1530,11 +1599,13 @@ func (r *Campaign) SetLureSettingsByID(
mode string,
algorithm string,
length int,
+ isTraining bool,
) error {
row := map[string]any{
"lure_url_mode": mode,
"lure_code_algo": algorithm,
"lure_code_length": length,
+ "is_training": isTraining,
}
AddUpdatedAt(row)
res := r.DB.
@@ -1989,6 +2060,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
saveBrowserMetadata := nullable.NewNullableWithValue(row.SaveBrowserMetadata)
isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous)
isTest := nullable.NewNullableWithValue(row.IsTest)
+ isTraining := nullable.NewNullableWithValue(row.IsTraining)
obfuscate := nullable.NewNullableWithValue(row.Obfuscate)
// deprecated fields - kept for backward compatibility
webhookIncludeData := nullable.NewNullableWithValue(row.WebhookIncludeData)
@@ -2138,6 +2210,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
SaveSubmittedData: saveSubmittedData,
SaveBrowserMetadata: saveBrowserMetadata,
IsAnonymous: isAnonymous,
+ IsTraining: isTraining,
IsTest: isTest,
Obfuscate: obfuscate,
WebhookIncludeData: webhookIncludeData,
@@ -2330,9 +2403,11 @@ type reportRecipientRow struct {
Email string `gorm:"column:email"`
Department string `gorm:"column:department"`
Position string `gorm:"column:position"`
- ClickedLink bool `gorm:"column:clicked_link"`
- SubmittedData bool `gorm:"column:submitted_data"`
- Reported bool `gorm:"column:reported"`
+ ClickedLink bool `gorm:"column:clicked_link"`
+ SubmittedData bool `gorm:"column:submitted_data"`
+ Reported bool `gorm:"column:reported"`
+ TrainingStarted bool `gorm:"column:training_started"`
+ TrainingCompleted bool `gorm:"column:training_completed"`
}
// GetReportRecipients returns per-recipient click/submit/reported results for a campaign.
@@ -2352,7 +2427,9 @@ func (r *Campaign) GetReportRecipients(
rec.position,
CASE WHEN clicked.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS clicked_link,
CASE WHEN submitted.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS submitted_data,
- CASE WHEN reported.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS reported
+ CASE WHEN reported.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS reported,
+ CASE WHEN started.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS training_started,
+ CASE WHEN completed.recipient_id IS NOT NULL THEN 1 ELSE 0 END AS training_completed
FROM campaign_recipients cr
JOIN recipients rec ON rec.id = cr.recipient_id
LEFT JOIN (
@@ -2373,6 +2450,18 @@ func (r *Campaign) GetReportRecipients(
WHERE campaign_id = ? AND recipient_id IS NOT NULL
AND event_id = ?
) AS reported ON reported.recipient_id = cr.recipient_id
+ LEFT JOIN (
+ SELECT DISTINCT recipient_id
+ FROM campaign_events
+ WHERE campaign_id = ? AND recipient_id IS NOT NULL
+ AND event_id = ?
+ ) AS started ON started.recipient_id = cr.recipient_id
+ LEFT JOIN (
+ SELECT DISTINCT recipient_id
+ FROM campaign_events
+ WHERE campaign_id = ? AND recipient_id IS NOT NULL
+ AND event_id = ?
+ ) AS completed ON completed.recipient_id = cr.recipient_id
WHERE cr.campaign_id = ? AND cr.recipient_id IS NOT NULL
ORDER BY rec.last_name, rec.first_name
`,
@@ -2385,6 +2474,10 @@ func (r *Campaign) GetReportRecipients(
campaignID,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED],
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED],
+ campaignID,
).Scan(&rows)
if res.Error != nil {
@@ -2394,14 +2487,16 @@ func (r *Campaign) GetReportRecipients(
result := make([]model.ReportRecipient, 0, len(rows))
for _, row := range rows {
result = append(result, model.ReportRecipient{
- FirstName: row.FirstName,
- LastName: row.LastName,
- Email: row.Email,
- Department: row.Department,
- Position: row.Position,
- ClickedLink: row.ClickedLink,
- SubmittedData: row.SubmittedData,
- Reported: row.Reported,
+ FirstName: row.FirstName,
+ LastName: row.LastName,
+ Email: row.Email,
+ Department: row.Department,
+ Position: row.Position,
+ ClickedLink: row.ClickedLink,
+ SubmittedData: row.SubmittedData,
+ Reported: row.Reported,
+ TrainingStarted: row.TrainingStarted,
+ TrainingCompleted: row.TrainingCompleted,
})
}
return result, nil
diff --git a/backend/repository/campaignTemplate.go b/backend/repository/campaignTemplate.go
index 8e118a5..8c38d62 100644
--- a/backend/repository/campaignTemplate.go
+++ b/backend/repository/campaignTemplate.go
@@ -922,6 +922,7 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
urlPath := nullable.NewNullableWithValue(*vo.NewURLPathMust(row.URLPath))
isUsable := nullable.NewNullableWithValue(row.IsUsable)
+ isTraining := nullable.NewNullableWithValue(row.IsTraining)
return &model.CampaignTemplate{
ID: id,
@@ -957,6 +958,7 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
IsUsable: isUsable,
+ IsTraining: isTraining,
}, nil
}
diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go
index 58a754a..dc385eb 100644
--- a/backend/repository/recipient.go
+++ b/backend/repository/recipient.go
@@ -110,6 +110,7 @@ func (r *Recipient) GetRepeatOffenderCount(
WHERE ce.recipient_id = %s.id
AND ce.created_at >= ?
AND c.is_test = false
+ AND c.is_training = false
GROUP BY ce.recipient_id
HAVING COUNT(DISTINCT CASE
WHEN ce.event_id IN (?, ?, ?) THEN ce.campaign_id
@@ -180,6 +181,7 @@ func (r *Recipient) GetAll(
WHERE ce.recipient_id = %s.id
AND ce.created_at >= ?
AND c.is_test = false
+ AND c.is_training = false
GROUP BY ce.recipient_id
HAVING COUNT(DISTINCT CASE
WHEN ce.event_id IN (?, ?, ?) THEN ce.campaign_id
@@ -549,21 +551,42 @@ func (r *Recipient) GetStatsByID(
}
repeatOffenderTimeThreshold := time.Now().AddDate(0, -months, 0)
- // get campaign count
+ // get phishing campaign count, training campaigns are counted separately below
r.DB.Model(&database.CampaignRecipient{}).
Joins("JOIN campaigns ON campaigns.id = campaign_recipients.campaign_id").
- Where("campaign_recipients.recipient_id = ? AND campaigns.is_test = ?", id, false).
+ Where("campaign_recipients.recipient_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?", id, false, false).
Distinct("campaign_recipients.campaign_id").
Count(&stats.CampaignsParticiated)
+ // get training campaigns the recipient was scheduled in
+ r.DB.Model(&database.CampaignRecipient{}).
+ Joins("JOIN campaigns ON campaigns.id = campaign_recipients.campaign_id").
+ Where("campaign_recipients.recipient_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?", id, false, true).
+ Distinct("campaign_recipients.campaign_id").
+ Count(&stats.TrainingsAssigned)
+
+ // get training campaigns the recipient completed
+ r.DB.Model(&database.CampaignEvent{}).
+ Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
+ Where(
+ "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
+ id,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED],
+ false,
+ true,
+ ).
+ Distinct("campaign_events.campaign_id").
+ Count(&stats.TrainingsCompleted)
+
// get unique tracking pixels loaded
r.DB.Model(&database.CampaignEvent{}).
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ],
false,
+ false,
).
Distinct("campaign_events.campaign_id").
Count(&stats.CampaignsTrackingPixelLoaded)
@@ -572,12 +595,13 @@ func (r *Recipient) GetStatsByID(
r.DB.Model(&database.CampaignEvent{}).
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id IN (?,?,?) AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id IN (?,?,?) AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED],
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED],
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED],
false,
+ false,
).
Distinct("campaign_events.campaign_id").
Count(&stats.CampaignsPhishingPageLoaded)
@@ -586,10 +610,11 @@ func (r *Recipient) GetStatsByID(
r.DB.Model(&database.CampaignEvent{}).
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA],
false,
+ false,
).
Distinct("campaign_events.campaign_id").
Count(&stats.CampaignsDataSubmitted)
@@ -598,10 +623,11 @@ func (r *Recipient) GetStatsByID(
r.DB.Model(&database.CampaignEvent{}).
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
false,
+ false,
).
Distinct("campaign_events.campaign_id").
Count(&stats.CampaignsReported)
@@ -612,13 +638,14 @@ func (r *Recipient) GetStatsByID(
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Select("COUNT(DISTINCT campaign_events.campaign_id)").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id IN (?,?,?) AND campaign_events.created_at >= ? AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id IN (?,?,?) AND campaign_events.created_at >= ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED],
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED],
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED],
repeatOffenderTimeThreshold,
false,
+ false,
).
Scan(&linkClickCount)
@@ -635,11 +662,12 @@ func (r *Recipient) GetStatsByID(
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
Select("COUNT(DISTINCT campaign_events.campaign_id)").
Where(
- "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaign_events.created_at >= ? AND campaigns.is_test = ?",
+ "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaign_events.created_at >= ? AND campaigns.is_test = ? AND campaigns.is_training = ?",
id,
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA],
repeatOffenderTimeThreshold,
false,
+ false,
).
Scan(&submitCount)
diff --git a/backend/repository/reportTemplate.go b/backend/repository/reportTemplate.go
index a5edac4..aec2316 100644
--- a/backend/repository/reportTemplate.go
+++ b/backend/repository/reportTemplate.go
@@ -110,11 +110,13 @@ func (r *ReportTemplate) GetByID(
func (r *ReportTemplate) GetForCampaign(
ctx context.Context,
companyID *uuid.UUID,
+ isTraining bool,
) (*model.ReportTemplate, error) {
+ trainingCol := TableColumn(database.REPORT_TEMPLATE_TABLE, "is_training")
var row database.ReportTemplate
if companyID != nil {
res := r.DB.
- Where(TableColumn(database.REPORT_TEMPLATE_TABLE, "company_id")+" = ?", companyID).
+ Where(TableColumn(database.REPORT_TEMPLATE_TABLE, "company_id")+" = ? AND "+trainingCol+" = ?", companyID, isTraining).
First(&row)
if res.Error == nil {
return ToReportTemplate(&row)
@@ -124,6 +126,7 @@ func (r *ReportTemplate) GetForCampaign(
}
}
res := whereCompanyIsNull(r.DB, database.REPORT_TEMPLATE_TABLE).
+ Where(trainingCol+" = ?", isTraining).
First(&row)
if res.Error != nil {
return nil, res.Error
@@ -174,10 +177,11 @@ func ToReportTemplate(row *database.ReportTemplate) (*model.ReportTemplate, erro
}
content := nullable.NewNullableWithValue(*c)
return &model.ReportTemplate{
- ID: id,
- CreatedAt: row.CreatedAt,
- UpdatedAt: row.UpdatedAt,
- CompanyID: companyID,
- Content: content,
+ ID: id,
+ CreatedAt: row.CreatedAt,
+ UpdatedAt: row.UpdatedAt,
+ CompanyID: companyID,
+ Content: content,
+ IsTraining: nullable.NewNullableWithValue(row.IsTraining),
}, nil
}
diff --git a/backend/seed/reportTemplate.go b/backend/seed/reportTemplate.go
index 720ad82..e2044bc 100644
--- a/backend/seed/reportTemplate.go
+++ b/backend/seed/reportTemplate.go
@@ -10,14 +10,22 @@ import (
"gorm.io/gorm"
)
-// SeedReportTemplate inserts the default global report template if none exists.
-// The seeded template can be freely edited through the UI; this only runs when
-// no global template (company_id IS NULL) is present in the database.
+// SeedReportTemplate inserts the default global report templates if they are
+// missing. There is one global template per kind (phishing and awareness
+// training); each is seeded independently and only when absent, so a template
+// already edited through the UI is never overwritten.
func SeedReportTemplate(db *gorm.DB) error {
+ if err := seedGlobalReportTemplate(db, false, embedded.DefaultReportHTML); err != nil {
+ return err
+ }
+ return seedGlobalReportTemplate(db, true, embedded.DefaultTrainingReportHTML)
+}
+
+func seedGlobalReportTemplate(db *gorm.DB, isTraining bool, content string) error {
var count int64
res := db.
Model(&database.ReportTemplate{}).
- Where("company_id IS NULL").
+ Where("company_id IS NULL AND is_training = ?", isTraining).
Count(&count)
if res.Error != nil {
return errs.Wrap(res.Error)
@@ -29,10 +37,11 @@ func SeedReportTemplate(db *gorm.DB) error {
id := uuid.New()
now := time.Now().UTC()
row := &database.ReportTemplate{
- ID: &id,
- CreatedAt: &now,
- UpdatedAt: &now,
- Content: embedded.DefaultReportHTML,
+ ID: &id,
+ CreatedAt: &now,
+ UpdatedAt: &now,
+ Content: content,
+ IsTraining: isTraining,
// CompanyID intentionally nil → global template
}
res = db.Create(row)
diff --git a/backend/service/campaign.go b/backend/service/campaign.go
index 45f318a..de198c0 100644
--- a/backend/service/campaign.go
+++ b/backend/service/campaign.go
@@ -176,6 +176,14 @@ func (c *Campaign) Create(
if cTemplate == nil {
return nil, errors.New("attempted to create campaign with unusable template")
}
+ // the training nature is owned by the template, never the client. Set it from
+ // the template so the campaign row carries it from creation, before any
+ // schedule snapshot, and a crafted request cannot flip it.
+ isTrainingTemplate := false
+ if v, err := cTemplate.IsTraining.Get(); err == nil {
+ isTrainingTemplate = v
+ }
+ campaign.IsTraining = nullable.NewNullableWithValue(isTrainingTemplate)
// check uniqueness
var companyID *uuid.UUID
if cid, err := campaign.CompanyID.Get(); err == nil {
@@ -439,8 +447,8 @@ func (c *Campaign) insertScheduledRecipient(
}
}
-// snapshotLureSettings copies the template's lure URL settings onto the campaign
-// while it still holds no recipients.
+// snapshotLureSettings copies the template's lure URL settings and training flag
+// onto the campaign while it still holds no recipients.
//
// A campaign holding recipients has links out that resolve through those rows,
// so reading the template again could hand a new recipient a different URL form
@@ -482,35 +490,47 @@ func (c *Campaign) snapshotLureSettings(
mode := data.LureURLModeQuery
algorithm := string(lure.DefaultAlgorithm)
length := lure.DefaultLength
+ isTraining := false
- if templateID, err := campaign.TemplateID.Get(); err == nil {
- cTemplate, err := c.CampaignTemplateService.GetByID(
- ctx,
- session,
- &templateID,
- &repository.CampaignTemplateOption{},
+ templateID, err := campaign.TemplateID.Get()
+ if err != nil {
+ // the template is gone, so the defaults below would be a guess that
+ // overwrites a snapshot taken while the template still existed
+ c.Logger.Errorw("campaign has no template, leaving the snapshot unwritten",
+ "campaignID", campaignID.String(),
)
- if err != nil || cTemplate == nil {
- c.Logger.Errorw("could not read lure settings from template, leaving the snapshot unwritten",
- "campaignID", campaignID.String(),
- "templateID", templateID.String(),
- "error", err,
- )
- return
- }
- if v, err := cTemplate.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
- mode = v
- }
- if v, err := cTemplate.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
- algorithm = v
- }
- if v, err := cTemplate.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
- length = v
- }
+ return
+ }
+ cTemplate, err := c.CampaignTemplateService.GetByID(
+ ctx,
+ session,
+ &templateID,
+ &repository.CampaignTemplateOption{},
+ )
+ if err != nil || cTemplate == nil {
+ c.Logger.Errorw("could not read lure settings from template, leaving the snapshot unwritten",
+ "campaignID", campaignID.String(),
+ "templateID", templateID.String(),
+ "error", err,
+ )
+ return
+ }
+ if v, err := cTemplate.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
+ mode = v
+ }
+ if v, err := cTemplate.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
+ algorithm = v
+ }
+ if v, err := cTemplate.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
+ length = v
+ }
+ if v, err := cTemplate.IsTraining.Get(); err == nil {
+ isTraining = v
}
campaign.LureURLMode = nullable.NewNullableWithValue(mode)
campaign.LureCodeAlgo = nullable.NewNullableWithValue(algorithm)
campaign.LureCodeLength = nullable.NewNullableWithValue(length)
+ campaign.IsTraining = nullable.NewNullableWithValue(isTraining)
if err := c.CampaignRepository.SetLureSettingsByID(
ctx,
@@ -518,6 +538,7 @@ func (c *Campaign) snapshotLureSettings(
mode,
algorithm,
length,
+ isTraining,
); err != nil {
// the in memory campaign still carries them, so this run allocates
// correctly even unpersisted
@@ -5328,6 +5349,11 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
campaignType = "self-managed"
}
+ isTrainingCampaign := false
+ if v, err := campaign.IsTraining.Get(); err == nil {
+ isTrainingCampaign = v
+ }
+
// Get template name with proper session
templateName := ""
templateID := campaign.TemplateID.MustGet()
@@ -5381,9 +5407,12 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
WebsiteVisits: int(resultStats.WebsiteLoaded),
DataSubmissions: int(resultStats.SubmittedData),
Reported: int(resultStats.Reported),
+ TrainingStarted: int(resultStats.TrainingStarted),
+ TrainingCompleted: int(resultStats.TrainingCompleted),
TemplateName: templateName,
CampaignType: campaignType,
+ IsTraining: isTrainingCampaign,
CreatedAt: &now,
UpdatedAt: &now,
}
@@ -5917,7 +5946,12 @@ func (c *Campaign) buildReportHTMLWithData(
companyID = &cid
}
- reportTmpl, tmplErr := c.ReportTemplateRepository.GetForCampaign(ctx, companyID)
+ isTrainingCampaign := false
+ if v, err := campaign.IsTraining.Get(); err == nil {
+ isTrainingCampaign = v
+ }
+
+ reportTmpl, tmplErr := c.ReportTemplateRepository.GetForCampaign(ctx, companyID, isTrainingCampaign)
if tmplErr != nil {
c.Logger.Errorw("failed to fetch report template", "error", tmplErr)
return "", nil, "", errs.Wrap(tmplErr)
@@ -5968,7 +6002,7 @@ func (c *Campaign) buildReportHTMLWithData(
}
const defaultReportEmailSubject = "Campaign report: {{.CampaignName}}"
-const defaultReportEmailBody = "The phishing simulation report for {{.CampaignName}} is attached.
"
+const defaultReportEmailBody = "The {{if .IsTraining}}awareness training{{else}}phishing simulation{{end}} report for {{.CampaignName}} is attached.
"
// renderReportEmailField renders a report email subject or body template with the
// given data. on a parse or execute error it logs and falls back to the default
@@ -6320,6 +6354,10 @@ func buildReportData(
stats *model.CampaignResultView,
recipients []model.ReportRecipient,
) *model.ReportData {
+ isTrainingReport := false
+ if v, err := campaign.IsTraining.Get(); err == nil {
+ isTrainingReport = v
+ }
total := stats.Recipients
rate := func(count int64) float64 {
if total == 0 {
@@ -6376,6 +6414,17 @@ func buildReportData(
OpenedOfSent: relPct(stats.TrackingPixelLoaded, stats.EmailsSent),
ClickedOfOpened: relPct(stats.WebsiteLoaded, stats.TrackingPixelLoaded),
SubmittedOfClicked: relPct(stats.SubmittedData, stats.WebsiteLoaded),
- Recipients: recipients,
+
+ IsTraining: isTrainingReport,
+ TrainingStarted: stats.TrainingStarted,
+ TrainingCompleted: stats.TrainingCompleted,
+ TrainingStartedPercent: pct(stats.TrainingStarted),
+ TrainingCompletedPercent: pct(stats.TrainingCompleted),
+ TrainingStartedRate: rate(stats.TrainingStarted),
+ TrainingCompletedRate: rate(stats.TrainingCompleted),
+ StartedOfOpened: relPct(stats.TrainingStarted, stats.TrackingPixelLoaded),
+ CompletedOfStarted: relPct(stats.TrainingCompleted, stats.TrainingStarted),
+
+ Recipients: recipients,
}
}
diff --git a/backend/service/campaignTemplate.go b/backend/service/campaignTemplate.go
index ddf864c..0ea42e4 100644
--- a/backend/service/campaignTemplate.go
+++ b/backend/service/campaignTemplate.go
@@ -746,6 +746,9 @@ func (c *CampaignTemplate) UpdateByID(
if v, err := campaignTemplate.LureCodeLength.Get(); err == nil {
incoming.LureCodeLength.Set(v)
}
+ if v, err := campaignTemplate.IsTraining.Get(); err == nil {
+ incoming.IsTraining.Set(v)
+ }
// validate
if err := incoming.Validate(); err != nil {
c.Logger.Errorw("failed to validate campaign template", "error", err)
diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js
index 0a3702b..5c3a474 100644
--- a/frontend/src/lib/api/api.js
+++ b/frontend/src/lib/api/api.js
@@ -1130,7 +1130,8 @@ export class API {
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
- lureCodeLength
+ lureCodeLength,
+ isTraining
}) => {
return await postJSON(this.getPath('/campaign/template'), {
name: name,
@@ -1151,7 +1152,8 @@ export class API {
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
- lureCodeLength: lureCodeLength
+ lureCodeLength: lureCodeLength,
+ isTraining: isTraining
});
},
@@ -1201,7 +1203,8 @@ export class API {
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
- lureCodeLength
+ lureCodeLength,
+ isTraining
}) => {
return await postJSON(this.getPath(`/campaign/template/${id}`), {
name: name,
@@ -1222,7 +1225,8 @@ export class API {
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
- lureCodeLength: lureCodeLength
+ lureCodeLength: lureCodeLength,
+ isTraining: isTraining
});
},
diff --git a/frontend/src/lib/components/CampaignCalendar.svelte b/frontend/src/lib/components/CampaignCalendar.svelte
index a03a832..ced6b64 100644
--- a/frontend/src/lib/components/CampaignCalendar.svelte
+++ b/frontend/src/lib/components/CampaignCalendar.svelte
@@ -497,7 +497,8 @@
? `${formatDateString(new Date(campaign.createdAt))} - ?`
: `${formatDateString(campaign.start)} - ${formatDateString(campaign.end)}`;
- let tooltip = `${campaign.name} - ${status}, ${dateRange}`;
+ const kind = campaign.isTraining ? 'Training - ' : '';
+ let tooltip = `${campaign.name} - ${kind}${status}, ${dateRange}`;
if (showCompany && campaign.company?.name) {
tooltip = `[${campaign.company.name}] ${tooltip}`;
@@ -685,6 +686,19 @@
{/each}
+
+
+
+
+ Training
+
@@ -832,9 +846,22 @@
{/if}
- {truncateText(campaign.name, 24)}
+ {#if campaign.isTraining}
+
+
+
+ {/if}
+
{truncateText(campaign.name, 24)}
diff --git a/frontend/src/lib/components/CampaignTrendChart.svelte b/frontend/src/lib/components/CampaignTrendChart.svelte
index 5780769..4d84524 100644
--- a/frontend/src/lib/components/CampaignTrendChart.svelte
+++ b/frontend/src/lib/components/CampaignTrendChart.svelte
@@ -34,6 +34,7 @@
const MOVING_AVG_N_KEY = 'campaignTrend.movingAvgN';
const LOG_SCALE_KEY = 'campaignTrend.useLogScale';
const RELATIVE_METRICS_KEY = 'campaignTrend.useRelativeMetrics';
+ const CHART_MODE_KEY = 'campaignTrend.chartMode';
let chartContainer;
let sizingContainer;
@@ -204,6 +205,9 @@
let xScale, yScale;
let useLogScale = false;
let useRelativeMetrics = false;
+ // 'phishing' shows the read/click/submission/report funnel, 'training' shows the
+ // awareness training funnel. Only offered when training campaigns exist.
+ let chartMode = 'phishing';
// load saved settings from localStorage
try {
@@ -224,6 +228,26 @@
// ignore errors
}
+ try {
+ const storedChartMode = localStorage.getItem(CHART_MODE_KEY);
+ if (storedChartMode === 'training' || storedChartMode === 'phishing') {
+ chartMode = storedChartMode;
+ }
+ } catch (e) {
+ // ignore errors
+ }
+
+ // the training mode is only meaningful when there are training campaigns
+ $: hasTraining = (campaignStats || []).some((s) => s.isTraining);
+ $: if (!hasTraining && chartMode !== 'phishing') {
+ chartMode = 'phishing';
+ }
+ $: try {
+ localStorage.setItem(CHART_MODE_KEY, chartMode);
+ } catch (e) {
+ // ignore errors
+ }
+
// Time range filter for campaigns
const timeRanges = [
{ label: 'Last 3 months', value: '3' },
@@ -261,12 +285,48 @@
});
})();
- const metrics = [
+ // a metric with a `mavg` also renders a dashed moving-average line and legend toggle
+ const phishingMetrics = [
{ key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' },
- { key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' },
- { key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' },
- { key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' }
+ {
+ key: 'clickRate',
+ label: 'Click Rate',
+ color: '#f96dcf',
+ suffix: '%',
+ mavg: { color: '#eea5fa', label: 'Click MA' }
+ },
+ {
+ key: 'submissionRate',
+ label: 'Submission Rate',
+ color: '#f42e41',
+ suffix: '%',
+ mavg: { color: '#ff6a91', label: 'Submit MA' }
+ },
+ {
+ key: 'reportRate',
+ label: 'Report Rate',
+ color: '#1e40af',
+ suffix: '%',
+ mavg: { color: '#60a5fa', label: 'Report MA' }
+ }
];
+ const trainingMetrics = [
+ {
+ key: 'startedRate',
+ label: 'Started Rate',
+ color: '#5b93e6',
+ suffix: '%',
+ mavg: { color: '#93c5fd', label: 'Started MA' }
+ },
+ {
+ key: 'completionRate',
+ label: 'Completion Rate',
+ color: '#2fa968',
+ suffix: '%',
+ mavg: { color: '#69e1ab', label: 'Completed MA' }
+ }
+ ];
+ $: metrics = chartMode === 'training' ? trainingMetrics : phishingMetrics;
// toggle metric visibility (reassign to trigger svelte reactivity and persist)
function toggleMetric(metricKey) {
@@ -281,14 +341,12 @@
const n = Math.min(trendN, chartData.length);
if (n === 0) return null;
const slice = chartData.slice(-n);
- const avg = (arr, key) => arr.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
- return {
- n,
- openRate: avg(slice, 'openRate'),
- clickRate: avg(slice, 'clickRate'),
- submissionRate: avg(slice, 'submissionRate'),
- reportRate: avg(slice, 'reportRate')
- };
+ const avg = (key) => slice.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
+ const out = { n };
+ for (const m of metrics) {
+ out[m.key] = avg(m.key);
+ }
+ return out;
})();
// --- Force chart rerender ---
@@ -298,9 +356,12 @@
.join('-');
// --- Data processing ---
- function processData(stats, useRelativeMetrics) {
+ function processData(stats, useRelativeMetrics, mode) {
+ // phishing and training campaigns are different sets with different funnels,
+ // so the chart shows one at a time based on the selected mode
+ const wantTraining = mode === 'training';
const sortedStats = [...(stats || [])]
- .filter((stat) => stat.campaignClosedAt)
+ .filter((stat) => stat.campaignClosedAt && !!stat.isTraining === wantTraining)
.sort(
(a, b) => new Date(a.campaignClosedAt).getTime() - new Date(b.campaignClosedAt).getTime()
);
@@ -310,25 +371,39 @@
return d > 0 ? Math.round((n / d) * 1000) / 10 : 0;
}
- return sortedStats.map((stat, index) => ({
- index: index + 1,
- date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
- name: stat.campaignName || `Campaign ${index + 1}`,
- campaignId: stat.campaignId,
- openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
- clickRate: useRelativeMetrics
- ? pct(stat.websiteVisits, stat.trackingPixelLoaded)
- : pct(stat.websiteVisits, stat.totalRecipients),
- submissionRate: useRelativeMetrics
- ? pct(stat.dataSubmissions, stat.websiteVisits)
- : pct(stat.dataSubmissions, stat.totalRecipients),
- reportRate: pct(stat.reported, stat.totalRecipients),
- totalRecipients: stat.totalRecipients
- }));
+ return sortedStats.map((stat, index) => {
+ const base = {
+ index: index + 1,
+ date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
+ name: stat.campaignName || `Campaign ${index + 1}`,
+ campaignId: stat.campaignId,
+ totalRecipients: stat.totalRecipients
+ };
+ if (wantTraining) {
+ return {
+ ...base,
+ startedRate: pct(stat.trainingStarted, stat.totalRecipients),
+ completionRate: useRelativeMetrics
+ ? pct(stat.trainingCompleted, stat.trainingStarted)
+ : pct(stat.trainingCompleted, stat.totalRecipients)
+ };
+ }
+ return {
+ ...base,
+ openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
+ clickRate: useRelativeMetrics
+ ? pct(stat.websiteVisits, stat.trackingPixelLoaded)
+ : pct(stat.websiteVisits, stat.totalRecipients),
+ submissionRate: useRelativeMetrics
+ ? pct(stat.dataSubmissions, stat.websiteVisits)
+ : pct(stat.dataSubmissions, stat.totalRecipients),
+ reportRate: pct(stat.reported, stat.totalRecipients)
+ };
+ });
}
- // Use filtered campaign stats based on selected time range and relative metrics toggle
- $: chartData = processData(filteredCampaignStats, useRelativeMetrics);
+ // Use filtered campaign stats based on selected time range, relative metrics and mode
+ $: chartData = processData(filteredCampaignStats, useRelativeMetrics, chartMode);
function createChart() {
if (!chartContainer || chartData.length < 2) return;
@@ -385,10 +460,9 @@
});
}
});
- // Only draw moving average for clickRate, submissionRate, and reportRate that are visible
- ['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => {
- const metric = metrics.find((m) => m.key === metricKey);
- if (metric && visibleMetrics[`mavg-${metricKey}`]) {
+ // draw the moving average for any metric that supports one and is toggled on
+ metrics.forEach((metric) => {
+ if (metric.mavg && visibleMetrics[`mavg-${metric.key}`]) {
createMovingAverageLine(svg, metric, movingAvgN);
}
});
@@ -456,15 +530,8 @@
if (started) {
path.setAttribute('d', pathData);
path.setAttribute('fill', 'none');
- // Use a lighter shade of the metric color for moving average
- let avgColor = metric.color;
- if (metric.key === 'openRate') {
- avgColor = '#93c5fd'; // light blue
- } else if (metric.key === 'submissionRate') {
- avgColor = '#ff6a91'; // lighter red, closer to #f42e41
- } else if (metric.key === 'reportRate') {
- avgColor = '#60a5fa'; // lighter blue for report rate
- }
+ // lighter shade defined on the metric, falling back to the metric color
+ const avgColor = (metric.mavg && metric.mavg.color) || metric.color;
path.setAttribute('stroke', avgColor);
path.setAttribute('stroke-width', '1.2');
path.setAttribute('stroke-dasharray', '6,4');
@@ -822,29 +889,12 @@
strokeDasharray: null,
opacity: 1
});
- if (
- metric.key === 'clickRate' ||
- metric.key === 'submissionRate' ||
- metric.key === 'reportRate'
- ) {
- // Use a lighter version of the main color for moving averages
- let avgColor = metric.color;
- let avgLabel = '';
- if (metric.key === 'clickRate') {
- avgColor = '#eea5fa'; // before-page-visited, lighter pink
- avgLabel = 'Click MA';
- } else if (metric.key === 'submissionRate') {
- avgColor = '#ff6a91'; // lighter red, closer to #f42e41
- avgLabel = 'Submit MA';
- } else if (metric.key === 'reportRate') {
- avgColor = '#60a5fa'; // lighter blue for report rate
- avgLabel = 'Report MA';
- }
+ if (metric.mavg) {
legendItems.push({
type: 'mavg',
key: metric.key,
- label: avgLabel,
- color: avgColor,
+ label: metric.mavg.label,
+ color: metric.mavg.color,
class: `legend-line legend-mavg legend-mavg-${metric.key}`,
labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`,
dataMetric: `mavg-${metric.key}`,
@@ -1226,6 +1276,7 @@
// reference toggles so Svelte tracks them for reactivity
useLogScale;
useRelativeMetrics;
+ chartMode;
createChart();
}
}
@@ -1319,6 +1370,30 @@
Trendline: Last {trendStats ? trendStats.n : campaignStats.length} Campaigns (average)
+ {#if hasTraining}
+
+ (chartMode = 'phishing')}
+ >
+ Phishing
+
+ (chartMode = 'training')}
+ >
+ Training
+
+
+ {/if}
@@ -1383,7 +1458,10 @@
{#if campaignStats.length > 0}
-
+
{#each metrics as metric}
diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte
index 0c53ef1..8286ba9 100644
--- a/frontend/src/lib/components/EventTimeline.svelte
+++ b/frontend/src/lib/components/EventTimeline.svelte
@@ -26,6 +26,8 @@
campaign_recipient_deny_page_visited: '#ff6b35',
campaign_recipient_submitted_data: '#f42e41',
campaign_recipient_reported: '#2c3e50',
+ campaign_recipient_training_started: '#5b93e6',
+ campaign_recipient_training_completed: '#2fa968',
campaign_recipient_info: '#94cae6'
});
@@ -62,6 +64,10 @@
'
',
campaign_recipient_reported:
'
',
+ campaign_recipient_training_started:
+ '
',
+ campaign_recipient_training_completed:
+ '
',
campaign_recipient_info:
'
'
});
@@ -130,6 +136,8 @@
campaign_recipient_deny_page_visited: true,
campaign_recipient_submitted_data: true,
campaign_recipient_reported: true,
+ campaign_recipient_training_started: true,
+ campaign_recipient_training_completed: true,
campaign_recipient_info: true
};
diff --git a/frontend/src/lib/components/StatsCard.svelte b/frontend/src/lib/components/StatsCard.svelte
index 925ffeb..c012461 100644
--- a/frontend/src/lib/components/StatsCard.svelte
+++ b/frontend/src/lib/components/StatsCard.svelte
@@ -43,7 +43,7 @@
+ training
+
diff --git a/frontend/src/lib/utils/events.js b/frontend/src/lib/utils/events.js
index fa1ae08..af066f6 100644
--- a/frontend/src/lib/utils/events.js
+++ b/frontend/src/lib/utils/events.js
@@ -41,6 +41,18 @@ export const eventNameMap = {
priority: 95,
color: 'bg-reported'
},
+ // the milestones outrank the raw page visits they are recorded on top of, as
+ // they do in the backend priorities
+ campaign_recipient_training_started: {
+ name: 'Training Started',
+ priority: 72,
+ color: 'bg-training-started'
+ },
+ campaign_recipient_training_completed: {
+ name: 'Training Completed',
+ priority: 75,
+ color: 'bg-training-completed'
+ },
campaign_recipient_info: {
name: 'Info',
priority: 25,
diff --git a/frontend/src/routes/campaign-template/+page.svelte b/frontend/src/routes/campaign-template/+page.svelte
index 5bf193d..201e242 100644
--- a/frontend/src/routes/campaign-template/+page.svelte
+++ b/frontend/src/routes/campaign-template/+page.svelte
@@ -33,6 +33,8 @@
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import { page } from '$app/stores';
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
+ import CheckboxField from '$lib/components/CheckboxField.svelte';
+ import TrainingLabel from '$lib/components/TrainingLabel.svelte';
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
import {
createTableSelection,
@@ -69,7 +71,8 @@
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
- lureCodeLength: 12
+ lureCodeLength: 12,
+ isTraining: false
};
// codes are matched exactly, so the choice is about which glyphs appear and
@@ -412,6 +415,7 @@
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
+ isTraining: formValues.isTraining,
companyID: contextCompanyID
});
if (!res.success) {
@@ -454,7 +458,8 @@
urlPath: formValues.urlPath || '',
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
- lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
+ lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
+ isTraining: formValues.isTraining
});
if (!res.success) {
modalError = res.error;
@@ -516,7 +521,8 @@
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
- lureCodeLength: 12
+ lureCodeLength: 12,
+ isTraining: false
};
modalError = '';
showAdvancedOptions = false;
@@ -632,6 +638,7 @@
formValues.lureURLMode = template.lureURLMode || 'query';
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
formValues.lureCodeLength = template.lureCodeLength || 12;
+ formValues.isTraining = !!template.isTraining;
// set advanced options visibility based on template configuration
showAdvancedOptions = !!(
@@ -712,7 +719,12 @@
title={template.name}
class="block w-full py-1 text-left"
>
- {template.name}
+
+ {#if template.isTraining}
+
+ {/if}
+ {template.name}
+
@@ -980,6 +992,14 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
+
+ Awareness training
+
diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte
index 5f335ef..dec9722 100644
--- a/frontend/src/routes/campaign/+page.svelte
+++ b/frontend/src/routes/campaign/+page.svelte
@@ -46,6 +46,7 @@
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TestLabel from '$lib/components/TestLabel.svelte';
+ import TrainingLabel from '$lib/components/TrainingLabel.svelte';
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
import {
@@ -169,6 +170,8 @@
'campaign_recipient_after_page_visited',
'campaign_recipient_evasion_page_visited',
'campaign_recipient_deny_page_visited',
+ 'campaign_recipient_training_started',
+ 'campaign_recipient_training_completed',
'campaign_closed'
];
@@ -183,7 +186,9 @@
campaign_recipient_before_page_visited: 'Before Page Visited',
campaign_recipient_page_visited: 'Page Visited',
campaign_recipient_after_page_visited: 'After Page Visited',
- campaign_recipient_deny_page_visited: 'Deny Page Visited'
+ campaign_recipient_deny_page_visited: 'Deny Page Visited',
+ campaign_recipient_training_started: 'Training Started',
+ campaign_recipient_training_completed: 'Training Completed'
};
// create display options array with nice names
@@ -203,10 +208,12 @@
campaign_recipient_before_page_visited: 1 << 6, // 64
campaign_recipient_page_visited: 1 << 7, // 128
campaign_recipient_after_page_visited: 1 << 8, // 256
- campaign_recipient_deny_page_visited: 1 << 9 // 512
+ campaign_recipient_deny_page_visited: 1 << 9, // 512
+ campaign_recipient_training_started: 1 << 10, // 1024
+ campaign_recipient_training_completed: 1 << 11 // 2048
};
- const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected
+ const WEBHOOK_EVENT_ALL_BITS = 4095; // 2^12 - 1, all 12 events selected
// convert array of event names to bitwise int
// if all events are selected, return 0 to preserve the "all events" semantic
@@ -1613,6 +1620,9 @@
{#if campaign.isTest}
{/if}
+ {#if campaign.isTraining}
+
+ {/if}
{campaign.name}
diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte
index e45b5b1..b108c54 100644
--- a/frontend/src/routes/campaign/[id]/+page.svelte
+++ b/frontend/src/routes/campaign/[id]/+page.svelte
@@ -78,6 +78,7 @@
saveSubmittedData: false,
saveBrowserMetadata: false,
isAnonymous: false,
+ isTraining: false,
scheduleAt: null,
allowDenyIDs: [],
@@ -113,7 +114,9 @@
trackingPixelLoaded: 0,
websiteLoaded: 0,
submittedData: 0,
- reported: 0
+ reported: 0,
+ trainingStarted: 0,
+ trainingCompleted: 0
};
// @ts-ignore
const recipientTableUrlParams = newTableURLParams({
@@ -320,6 +323,7 @@
campaign.closeAt = t.closeAt;
campaign.closedAt = t.closedAt;
campaign.isTest = t.isTest;
+ campaign.isTraining = t.isTraining;
campaign.constraintWeekDays = t.constraintWeekDays;
campaign.constraintStartTime = t.constraintStartTime;
campaign.constraintEndTime = t.constraintEndTime;
@@ -509,6 +513,8 @@
result.websiteLoaded = res.data.clickedLink;
result.submittedData = res.data.submittedData;
result.reported = res.data.reported;
+ result.trainingStarted = res.data.trainingStarted;
+ result.trainingCompleted = res.data.trainingCompleted;
} catch (e) {
addToast('Failed to load campaign result stats', 'Error');
console.error('failed to load campaign result stats', e);
@@ -1640,29 +1646,70 @@
-
+ {#if campaign.isTraining}
+
+
+
+
+
+ {:else}
+
-
+
+
+
+
+ {:else}
+
-
+
+
+
+
+ {:else}
+
+ {/if}
Event Timeline
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte
index 62f1761..ea31cfb 100644
--- a/frontend/src/routes/dashboard/+page.svelte
+++ b/frontend/src/routes/dashboard/+page.svelte
@@ -42,6 +42,14 @@
let calendarCampaigns = [];
let campaignStats = [];
+
+ // training completion rate across closed training campaigns, derived from the
+ // campaign stats snapshots already loaded for the trend chart
+ $: trainingStats = (campaignStats || []).filter((s) => s.isTraining);
+ $: trainingStarted = trainingStats.reduce((sum, s) => sum + (s.trainingStarted || 0), 0);
+ $: trainingCompleted = trainingStats.reduce((sum, s) => sum + (s.trainingCompleted || 0), 0);
+ $: trainingCompletionRate =
+ trainingStarted > 0 ? Math.round((trainingCompleted / trainingStarted) * 100) : 0;
let isCampaignStatsLoading = true; // start as true to show ghost on initial load
let calendarStartDate = null;
@@ -252,8 +260,8 @@
{contextCompanyName}
{/if}
-
-
+
{contextCompanyName ? 'Campaign Trends' : 'Shared Campaign Trends'}
diff --git a/frontend/src/routes/recipient/[id]/+page.svelte b/frontend/src/routes/recipient/[id]/+page.svelte
index 8515aea..2eb163f 100644
--- a/frontend/src/routes/recipient/[id]/+page.svelte
+++ b/frontend/src/routes/recipient/[id]/+page.svelte
@@ -39,7 +39,9 @@
campaignsTrackingPixelLoaded: 0,
campaignsPhishingPageLoaded: 0,
campaignsDataSubmitted: 0,
- campaignsReported: 0
+ campaignsReported: 0,
+ trainingsAssigned: 0,
+ trainingsCompleted: 0
};
let isGroupsLoading = false;
let isEventsLoading = false;
@@ -158,7 +160,7 @@
{/if}
Export events
-
+
+
+ {#if stats.trainingsAssigned > 0}
+
+ Training
+ Assigned {stats.trainingsAssigned}
+ Completed {stats.trainingsCompleted}
+
+ {/if}
Groups
{
try {
@@ -90,15 +93,11 @@
const openReportTemplateModal = async () => {
try {
showIsLoading();
- reportTemplateContent = '';
- reportTemplateID = null;
reportTemplateError = '';
+ reportKind = 'phishing';
const response = await api.reportTemplate.getAll(null);
- if (response.success && response.data?.rows?.length > 0) {
- const tmpl = response.data.rows[0];
- reportTemplateContent = tmpl.content || '';
- reportTemplateID = tmpl.id || null;
- }
+ reportRows = response.success ? response.data?.rows || [] : [];
+ selectReportKind('phishing');
} catch (error) {
console.error('Failed to load report template:', error);
reportTemplateError = 'Failed to load template';
@@ -108,6 +107,25 @@
}
};
+ // loads the cached template row for the selected kind into the editor
+ const selectReportKind = (kind) => {
+ reportKind = kind;
+ reportTemplateError = '';
+ const row = reportRows.find((r) => !!r.isTraining === (kind === 'training'));
+ reportTemplateContent = row?.content || '';
+ reportTemplateID = row?.id || null;
+ };
+
+ // re-fetch the templates after a save so the active kind keeps its id
+ const refreshReportRows = async () => {
+ const response = await api.reportTemplate.getAll(null);
+ reportRows = response.success ? response.data?.rows || [] : [];
+ const row = reportRows.find((r) => !!r.isTraining === (reportKind === 'training'));
+ if (row?.id) {
+ reportTemplateID = row.id;
+ }
+ };
+
const closeReportTemplateModal = () => {
isReportTemplateModalVisible = false;
reportTemplateError = '';
@@ -124,12 +142,16 @@
content: reportTemplateContent
});
} else {
- response = await api.reportTemplate.create({ content: reportTemplateContent });
+ response = await api.reportTemplate.create({
+ content: reportTemplateContent,
+ isTraining: reportKind === 'training'
+ });
if (response.success && response.data?.id) {
reportTemplateID = response.data.id;
}
}
if (response.success) {
+ await refreshReportRows();
addToast('Report template saved', 'Success');
if (!saveOnly) {
isReportTemplateModalVisible = false;
@@ -219,6 +241,31 @@
+
+
Report for
+
+ selectReportKind('phishing')}
+ >
+ Phishing
+
+ selectReportKind('training')}
+ >
+ Training
+
+
+
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 7bc169e..cdb4ea5 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -44,6 +44,8 @@ export default {
'deny-page-visited': '#ff6b35',
'submitted-data': '#f42e41',
reported: '#2c3e50',
+ 'training-started': '#5b93e6',
+ 'training-completed': '#2fa968',
'completed-campaign': '#48bb78',
'repeat-offenders': '#ff6768',
'emails-read': '#e8e810',