From a7fbd573f692083e0b2cee69da8b0536d465461e Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Sat, 15 Aug 2026 00:12:51 +0200 Subject: [PATCH] added awareness training campaign mode, report, template mode, and stats Signed-off-by: RonniSkansing --- backend/app/server.go | 163 +++++- backend/cache/local.go | 4 + backend/data/events.go | 17 +- backend/database/campaign.go | 4 + backend/database/campaignStats.go | 7 + backend/database/campaignTemplate.go | 4 + backend/database/campaignWebhook.go | 4 +- backend/database/reportTemplate.go | 19 +- backend/embedded/default_training_report.html | 513 ++++++++++++++++++ backend/embedded/files.go | 3 + backend/model/campaign.go | 8 + backend/model/campaignResultView.go | 3 + backend/model/campaignTemplate.go | 18 + backend/model/campaignWebhook.go | 2 +- backend/model/recipientCampaignStatsView.go | 3 + backend/model/reportTemplate.go | 40 +- backend/repository/campaign.go | 119 +++- backend/repository/campaignTemplate.go | 2 + backend/repository/recipient.go | 44 +- backend/repository/reportTemplate.go | 16 +- backend/seed/reportTemplate.go | 25 +- backend/service/campaign.go | 105 +++- backend/service/campaignTemplate.go | 3 + frontend/src/lib/api/api.js | 12 +- .../lib/components/CampaignCalendar.svelte | 33 +- .../lib/components/CampaignTrendChart.svelte | 208 ++++--- .../src/lib/components/EventTimeline.svelte | 8 + frontend/src/lib/components/StatsCard.svelte | 2 +- .../src/lib/components/TrainingLabel.svelte | 6 + frontend/src/lib/utils/events.js | 12 + .../src/routes/campaign-template/+page.svelte | 28 +- frontend/src/routes/campaign/+page.svelte | 16 +- .../src/routes/campaign/[id]/+page.svelte | 200 +++++-- frontend/src/routes/dashboard/+page.svelte | 43 +- .../src/routes/recipient/[id]/+page.svelte | 14 +- .../src/routes/settings/panels/Reports.svelte | 63 ++- frontend/tailwind.config.js | 2 + 37 files changed, 1540 insertions(+), 233 deletions(-) create mode 100644 backend/embedded/default_training_report.html create mode 100644 frontend/src/lib/components/TrainingLabel.svelte 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 received
read the email
+
+
+
{{.StartedOfOpened}}%
+
of those who read
started the training
+
+
+
{{.CompletedOfStarted}}%
+
of those who started
completed the training
+
+
+ + +
+ +{{if .Recipients}} + +
+ + + + + + + + + + + + + + {{range .Recipients}} + + + + + + + + {{end}} + +
NameEmailStartedCompletedStatus
{{.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}} + + + 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} +
+ + +
+ {/if}
{#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 +
+ + +
+
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',