added awareness training campaign mode, report, template mode, and stats

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-08-15 00:12:51 +02:00
parent 6804710d3c
commit a7fbd573f6
37 changed files with 1540 additions and 233 deletions
+139 -24
View File
@@ -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(&currentNotableEventID, 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
}
+4
View File
@@ -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,
+14 -3
View File
@@ -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,
}
+4
View File
@@ -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
+7
View File
@@ -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 {
+4
View File
@@ -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;"`
+3 -1
View File
@@ -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"`
}
+16 -3
View File
@@ -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
}
@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.CampaignName}}</title>
<style>
@page { margin: 0; size: A4 portrait; }
:root {
--dark: #111827;
--dark2: #1f2937;
--dark3: #374151;
--accent: #2563eb;
--white: #f9fafb;
--muted: rgba(249,250,251,0.65);
--border: rgba(249,250,251,0.08);
--danger: #ef4444;
--warning: #f59e0b;
--safe: #10b981;
--info: #2563eb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; width: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: var(--dark);
color: var(--white);
font-size: 13px;
line-height: 1.55;
}
/* COVER */
.cover {
width: 100%;
height: 297mm;
background: var(--dark);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 52px 60px;
position: relative;
page-break-after: always;
overflow: hidden;
}
/* Left-edge accent bar */
.cover::after {
content: '';
position: absolute;
left: 0; top: 15%; bottom: 15%;
width: 3px;
background: linear-gradient(to bottom, transparent, var(--accent) 30%, var(--accent) 70%, transparent);
}
/* Cover top: logo + confidential on the same baseline */
.cover-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.cover-logo { width: 176px; flex-shrink: 0; }
.cover-confidential {
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid rgba(249,250,251,0.14);
padding: 5px 12px;
}
/* Cover middle: eyebrow + large campaign name */
.cover-middle {
padding-left: 20px;
}
.cover-eyebrow {
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 16px;
}
.cover-campaign-name {
font-size: 42px;
font-weight: 700;
line-height: 1.08;
color: var(--white);
word-break: break-word;
hyphens: auto;
max-width: 520px;
}
.cover-campaign-label {
font-size: 15px;
font-weight: 300;
color: var(--muted);
margin-top: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* Cover bottom: divider + meta */
.cover-bottom {
padding-left: 20px;
}
.cover-rule {
width: 40px; height: 2px;
background: var(--accent);
margin-bottom: 20px;
}
.cover-meta {
display: grid;
grid-template-columns: 110px 1fr;
row-gap: 5px;
font-size: 12px;
max-width: 340px;
}
.cover-meta .key { color: var(--muted); }
.cover-meta .val { color: var(--white); font-weight: 500; }
/* PAGES */
.page {
width: 100%;
padding: 44px 60px 70px 60px;
page-break-after: always;
min-height: 297mm;
position: relative;
background: var(--dark);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid var(--border);
padding-bottom: 13px;
margin-bottom: 26px;
}
.page-header .section-label {
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
.page-header .company { font-size: 11px; color: var(--muted); }
.section-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 10px;
margin-top: 22px;
}
.section-title:first-child { margin-top: 0; }
/* Metric cards — 3-column */
.mcards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.mcard {
background: var(--dark2);
border: 1px solid var(--border);
border-top: 2px solid var(--dark3);
padding: 14px 13px 12px;
}
.mcard.c-neutral { border-top-color: var(--dark3); }
.mcard.c-info { border-top-color: var(--info); }
.mcard.c-warning { border-top-color: var(--warning); }
.mcard.c-danger { border-top-color: var(--danger); }
.mcard.c-safe { border-top-color: var(--safe); }
.mcard-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 6px;
}
.mcard-value {
font-size: 30px;
font-weight: 700;
line-height: 1;
margin-bottom: 3px;
color: var(--white);
}
.mcard-sub { font-size: 10px; color: var(--muted); }
/* Relative conversion cards */
.conv-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 22px;
}
.conv-card {
background: var(--dark2);
border: 1px solid var(--border);
padding: 11px 12px;
display: flex;
gap: 9px;
align-items: flex-start;
}
.conv-pct {
font-size: 18px;
font-weight: 700;
color: var(--white);
white-space: nowrap;
line-height: 1.2;
flex-shrink: 0;
}
.conv-label {
font-size: 10px;
color: var(--muted);
line-height: 1.5;
padding-top: 1px;
}
.conv-label strong { color: rgba(249,250,251,0.85); font-weight: 500; }
/* Donut charts */
.donuts-row {
display: flex;
justify-content: space-around;
align-items: center;
margin-bottom: 26px;
padding: 10px 20px;
}
.donut-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.donut-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
text-align: center;
}
.donut-count { font-size: 11px; color: var(--muted); text-align: center; }
/* General table */
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 22px; }
thead { display: table-header-group; }
thead tr { border-bottom: 1px solid var(--accent); }
th {
font-size: 9px;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--muted);
padding: 7px 9px;
text-align: left;
font-weight: 400;
}
td {
padding: 7px 9px;
border-bottom: 1px solid var(--border);
color: rgba(249,250,251,0.85);
word-break: break-word;
overflow-wrap: anywhere;
}
tr { page-break-inside: avoid; }
/* Badges */
.badge {
display: inline-block;
padding: 2px 7px;
font-size: 9px;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 600;
white-space: nowrap;
}
.badge-danger { background: rgba(239,68,68,0.14); color: var(--danger); }
.badge-warning { background: rgba(245,158,11,0.14); color: var(--warning); }
.badge-safe { background: rgba(16,185,129,0.14); color: var(--safe); }
.badge-info { background: rgba(37,99,235,0.14); color: var(--info); }
.badge-muted { background: rgba(55,65,81,0.5); color: var(--muted); }
/* Page footer */
.page-footer {
position: absolute;
bottom: 26px; left: 60px; right: 60px;
padding-top: 9px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--muted);
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.cover, .page { page-break-after: always; }
}
</style>
</head>
<body>
<!-- COVER -->
<div class="cover">
<div class="cover-top">
<svg class="cover-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 205.63 17.27">
<path fill="#f9fafb" d="m13.18,2.73v8.15l-2.63,2.63H3.46v3.66H0V.1h10.54l2.63,2.63ZM3.46,10.69h6.25V2.93H3.46v7.76Z"/>
<path fill="#f9fafb" d="m15.22,17.18V.1h3.46v6.73h6.69V.1h3.46v17.08h-3.46v-7.22h-6.69v7.22h-3.46Z"/>
<path fill="#f9fafb" d="m31.21,17.27v-2.78l2.42-.12V2.9l-2.42-.12V0l4.15.2,4.15-.2v2.78l-2.42.12v11.47l2.42.12v2.78l-4.15-.2-4.15.2Z"/>
<path fill="#f9fafb" d="m42.06,17.18v-3.03h9.47v-3.95l-9.71-.8V2.63L44.36.1h10.39v3.03h-9.47v3.71l9.71.81v7l-2.54,2.54h-10.39Z"/>
<path fill="#f9fafb" d="m57.48,17.18V.1h3.46v6.73h6.69V.1h3.46v17.08h-3.46v-7.22h-6.69v7.22h-3.46Z"/>
<path fill="#f9fafb" d="m73.46,17.27v-2.78l2.42-.12V2.9l-2.42-.12V0l4.15.2,4.15-.2v2.78l-2.42.12v11.47l2.42.12v2.78l-4.15-.2-4.15.2Z"/>
<path fill="#f9fafb" d="m84.13,17.18V.1h3.64l7.15,12.08V.1h3.27v17.08h-3.63l-7.15-12.08v12.08h-3.27Z"/>
<path fill="#f9fafb" d="m100.67,14.54V2.73l2.64-2.63h9.76v3.03h-8.93v11.03h6.73v-4.34h-4.1v-2.81h7.22v10.17h-10.69l-2.64-2.63Z"/>
<path fill="#f9fafb" d="m148.15,14.54V2.73l2.64-2.63h8.73v3.15h-7.81v10.78h8.05v3.15h-8.98l-2.64-2.63Z"/>
<path fill="#f9fafb" d="m161.72,17.18V.1h3.46v14.15h5.76v-3.9h3.12v6.83h-12.35Z"/>
<path fill="#f9fafb" d="m176.01,14.54V.1h3.46v13.96h6.39V.1h3.46v14.44l-2.63,2.63h-8.05l-2.64-2.63Z"/>
<path fill="#f9fafb" d="m205.15,2.63v4.32l-1.34,1.15,1.83,1.56v4.98l-2.54,2.54h-11.22V.1h10.74l2.54,2.54Zm-9.81,4.46h6.29V2.93h-6.29v4.17Zm0,7.25h6.73v-4.71h-6.73v4.71Z"/>
<path fill="#2563eb" d="m126.63,8.2l-4.5-2.6c-.58-.34-1.31.08-1.31.75v5.19c0,.67.73,1.09,1.31.75l4.5-2.6c.58-.34.58-1.17,0-1.51Z"/>
<path fill="#2563eb" d="m140.97,7.67l-11.77-6.8c-.99-.57-2.23.14-2.23,1.28v3.81l3.18,1.84c.76.44.76,1.54,0,1.98l-3.18,1.84v4.13c0,1.14,1.24,1.86,2.23,1.28l11.77-6.8c.99-.57.99-2,0-2.57Z"/>
</svg>
<div class="cover-confidential">Confidential</div>
</div>
<div class="cover-middle">
<div class="cover-eyebrow">Awareness Training Report</div>
<div class="cover-campaign-name">{{.CampaignName}}</div>
<div class="cover-campaign-label">Training Results</div>
</div>
<div class="cover-bottom">
<div class="cover-rule"></div>
<div class="cover-meta">
{{if .CompanyName}}
<span class="key">Company</span><span class="val">{{.CompanyName}}</span>
{{end}}
<span class="key">Date</span><span class="val">{{.ReportDate}}</span>
{{if .CampaignStartDate}}
<span class="key">Started</span><span class="val">{{.CampaignStartDate}}</span>
{{end}}
{{if .CampaignClosedAt}}
<span class="key">Closed</span><span class="val">{{.CampaignClosedAt}}</span>
{{end}}
</div>
</div>
</div>
<!-- PAGE 1: RESULTS -->
<div class="page">
<div class="page-header">
<span class="section-label">Results</span>
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
</div>
<div class="section-title">Training results</div>
<div class="mcards">
<div class="mcard c-neutral">
<div class="mcard-label">Recipients</div>
<div class="mcard-value">{{.TotalTargets}}</div>
<div class="mcard-sub">total assigned</div>
</div>
<div class="mcard c-neutral">
<div class="mcard-label">Emails Sent</div>
<div class="mcard-value">{{.EmailsSent}}</div>
<div class="mcard-sub">{{printf "%.0f" .SentRate}}% of recipients</div>
</div>
<div class="mcard c-info">
<div class="mcard-label">Emails Read</div>
<div class="mcard-value">{{.EmailsOpened}}</div>
<div class="mcard-sub">{{printf "%.0f" .OpenRate}}% of recipients</div>
</div>
</div>
<div class="mcards">
<div class="mcard c-info">
<div class="mcard-label">Training Started</div>
<div class="mcard-value">{{.TrainingStarted}}</div>
<div class="mcard-sub">{{.TrainingStartedPercent}}% of recipients</div>
</div>
<div class="mcard c-safe">
<div class="mcard-label">Training Completed</div>
<div class="mcard-value">{{.TrainingCompleted}}</div>
<div class="mcard-sub">{{.TrainingCompletedPercent}}% of recipients</div>
</div>
<div class="mcard c-safe">
<div class="mcard-label">Completion Rate</div>
<div class="mcard-value">{{.CompletedOfStarted}}%</div>
<div class="mcard-sub">of those who started</div>
</div>
</div>
<div class="section-title">Outcome</div>
<div class="donuts-row">
<div class="donut-item">
<svg viewBox="0 0 140 140" width="136" height="136">
<circle cx="70" cy="70" r="50" fill="none" stroke="rgba(249,250,251,0.06)" stroke-width="14"/>
{{if .OpenRate}}
<circle cx="70" cy="70" r="50" fill="none" stroke="#2563eb" stroke-width="14"
stroke-linecap="round"
stroke-dasharray="{{printf "%.2f" (mul .OpenRate 3.1416)}} 314.16"
transform="rotate(-90 70 70)"/>
{{end}}
<text x="70" y="66" text-anchor="middle" font-size="20" font-weight="700" fill="#f9fafb">{{printf "%.0f" .OpenRate}}%</text>
<text x="70" y="81" text-anchor="middle" font-size="9" fill="rgba(249,250,251,0.55)">of recipients</text>
</svg>
<div class="donut-label" style="color:#2563eb">Emails Read</div>
<div class="donut-count">{{.EmailsOpened}} recipients</div>
</div>
<div class="donut-item">
<svg viewBox="0 0 140 140" width="136" height="136">
<circle cx="70" cy="70" r="50" fill="none" stroke="rgba(249,250,251,0.06)" stroke-width="14"/>
{{if .TrainingStartedRate}}
<circle cx="70" cy="70" r="50" fill="none" stroke="#5b93e6" stroke-width="14"
stroke-linecap="round"
stroke-dasharray="{{printf "%.2f" (mul .TrainingStartedRate 3.1416)}} 314.16"
transform="rotate(-90 70 70)"/>
{{end}}
<text x="70" y="66" text-anchor="middle" font-size="20" font-weight="700" fill="#f9fafb">{{.TrainingStartedPercent}}%</text>
<text x="70" y="81" text-anchor="middle" font-size="9" fill="rgba(249,250,251,0.55)">of recipients</text>
</svg>
<div class="donut-label" style="color:#5b93e6">Training Started</div>
<div class="donut-count">{{.TrainingStarted}} recipients</div>
</div>
<div class="donut-item">
<svg viewBox="0 0 140 140" width="136" height="136">
<circle cx="70" cy="70" r="50" fill="none" stroke="rgba(249,250,251,0.06)" stroke-width="14"/>
{{if .TrainingCompletedRate}}
<circle cx="70" cy="70" r="50" fill="none" stroke="#10b981" stroke-width="14"
stroke-linecap="round"
stroke-dasharray="{{printf "%.2f" (mul .TrainingCompletedRate 3.1416)}} 314.16"
transform="rotate(-90 70 70)"/>
{{end}}
<text x="70" y="66" text-anchor="middle" font-size="20" font-weight="700" fill="#f9fafb">{{.TrainingCompletedPercent}}%</text>
<text x="70" y="81" text-anchor="middle" font-size="9" fill="rgba(249,250,251,0.55)">of recipients</text>
</svg>
<div class="donut-label" style="color:#10b981">Training Completed</div>
<div class="donut-count">{{.TrainingCompleted}} recipients</div>
</div>
</div>
<div class="section-title">Conversion</div>
<div class="conv-row">
<div class="conv-card">
<div class="conv-pct">{{.OpenedOfSent}}%</div>
<div class="conv-label">of those who received<br><strong>read the email</strong></div>
</div>
<div class="conv-card">
<div class="conv-pct">{{.StartedOfOpened}}%</div>
<div class="conv-label">of those who read<br><strong>started the training</strong></div>
</div>
<div class="conv-card">
<div class="conv-pct">{{.CompletedOfStarted}}%</div>
<div class="conv-label">of those who started<br><strong>completed the training</strong></div>
</div>
</div>
<div class="page-footer">
<span>Awareness Training</span>
<span>{{.CampaignName}}</span>
</div>
</div>
{{if .Recipients}}
<!-- PAGE 2: INDIVIDUAL RESULTS -->
<div class="page">
<div class="page-header">
<span class="section-label">Individual results</span>
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th style="width:60px;text-align:center">Started</th>
<th style="width:70px;text-align:center">Completed</th>
<th style="width:90px">Status</th>
</tr>
</thead>
<tbody>
{{range .Recipients}}
<tr>
<td>{{.FirstName}} {{.LastName}}</td>
<td>{{.Email}}</td>
<td style="text-align:center">{{if .TrainingStarted}}<span style="color:#5b93e6;font-weight:600">Yes</span>{{else}}<span style="color:rgba(249,250,251,0.35)">-</span>{{end}}</td>
<td style="text-align:center">{{if .TrainingCompleted}}<span style="color:#10b981;font-weight:600">Yes</span>{{else}}<span style="color:rgba(249,250,251,0.35)">-</span>{{end}}</td>
<td>
{{if .TrainingCompleted}}<span class="badge badge-safe">Completed</span>
{{else if .TrainingStarted}}<span class="badge badge-warning">In Progress</span>
{{else}}<span class="badge badge-muted">Not Started</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="page-footer">
<span>Awareness Training</span>
<span>{{.CampaignName}}</span>
</div>
</div>
{{end}}
</body>
</html>
+3
View File
@@ -22,3 +22,6 @@ var SigningKey2 []byte
//go:embed default_report.html
var DefaultReportHTML string
//go:embed default_training_report.html
var DefaultTrainingReportHTML string
+8
View File
@@ -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 {
+3
View File
@@ -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"`
}
+18
View File
@@ -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()
+1 -1
View File
@@ -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
@@ -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"`
}
+30 -10
View File
@@ -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
}
+107 -12
View File
@@ -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
+2
View File
@@ -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
}
+36 -8
View File
@@ -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)
+10 -6
View File
@@ -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
}
+17 -8
View File
@@ -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)
+77 -28
View File
@@ -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 = "<p>The phishing simulation report for <strong>{{.CampaignName}}</strong> is attached.</p>"
const defaultReportEmailBody = "<p>The {{if .IsTraining}}awareness training{{else}}phishing simulation{{end}} report for <strong>{{.CampaignName}}</strong> is attached.</p>"
// 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,
}
}
+3
View File
@@ -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)
+8 -4
View File
@@ -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
});
},
@@ -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 @@
</span>
</button>
{/each}
<span class="flex items-center text-xs text-gray-700 dark:text-gray-300">
<svg
class="h-3.5 w-3.5 mr-1 text-training-completed"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
/>
</svg>
Training
</span>
</div>
</div>
@@ -832,9 +846,22 @@
</div>
{/if}
<div
class="campaign-name truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
class="campaign-name flex items-center gap-1 truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
>
{truncateText(campaign.name, 24)}
{#if campaign.isTraining}
<svg
class="flex-shrink-0 h-3 w-3 text-training-completed"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-label="Training"
>
<path
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
/>
</svg>
{/if}
<span class="truncate">{truncateText(campaign.name, 24)}</span>
</div>
</div>
</a>
@@ -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)
</h4>
<div class="flex flex-wrap items-center gap-2 mb-0">
{#if hasTraining}
<div
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-xs"
>
<button
type="button"
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'phishing'
? 'bg-cta-blue text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => (chartMode = 'phishing')}
>
Phishing
</button>
<button
type="button"
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'training'
? 'bg-training-completed text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => (chartMode = 'training')}
>
Training
</button>
</div>
{/if}
<label
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
>
@@ -1383,7 +1458,10 @@
</div>
<div class="mb-8"></div>
{#if campaignStats.length > 0}
<div class="grid grid-cols-4 gap-2 sm:gap-4">
<div
class="grid gap-2 sm:gap-4"
style="grid-template-columns: repeat({metrics.length}, minmax(0, 1fr));"
>
{#each metrics as metric}
<div class="text-center">
<div class="flex items-center justify-center">
@@ -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 @@
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_reported:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
campaign_recipient_training_started:
'<svg fill="currentColor" viewBox="0 0 24 24"><path d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.949 49.949 0 0 0-9.902 3.912l-.003.002a48.9 48.9 0 0 0-.34.18.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.615 54.615 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.123 56.123 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.803 49.803 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.653 60.653 0 0 1 11.7 2.805Z"/></svg>',
campaign_recipient_training_completed:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_info:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
});
@@ -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
};
+1 -1
View File
@@ -43,7 +43,7 @@
</script>
<div
class="bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
class="h-full bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
>
<div
class="text-grayblue-dark dark:text-gray-500 text-sm font-semibold transition-colors duration-200"
@@ -0,0 +1,6 @@
<span
title="Awareness training"
class="select-none px-1 border-2 border-training-completed relative -top-1 rounded-lg text-xs text-training-completed bg-white dark:bg-gray-800 transition-colors duration-200"
>
training
</span>
+12
View File
@@ -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,
@@ -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}
<span class="flex items-center gap-2">
{#if template.isTraining}
<TrainingLabel />
{/if}
{template.name}
</span>
</button>
</TableCell>
<TableCell>
@@ -980,6 +992,14 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
</div>
</div>
<CheckboxField
id="isTraining"
inline
bind:value={formValues.isTraining}
toolTipText="Tracks training events instead of phishing events."
>
Awareness training
</CheckboxField>
</div>
<!-- Delivery Configuration Section -->
+13 -3
View File
@@ -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}
<TestLabel />
{/if}
{#if campaign.isTraining}
<TrainingLabel />
{/if}
{campaign.name}
</TableCellLink>
<TableCell>
+166 -34
View File
@@ -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 @@
</svg>
</StatsCard>
<StatsCard
title="Website Visits"
value={result.websiteLoaded}
borderColor="border-page-visited"
iconColor="text-page-visited"
percentages={[
{
value: Math.round((result.websiteLoaded / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
{#if campaign.isTraining}
<StatsCard
title="Training Started"
value={result.trainingStarted}
borderColor="border-training-started"
iconColor="text-training-started"
percentages={[
{
value: Math.round((result.trainingStarted / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.trainingStarted / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.trainingStarted / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Website Visits"
value={result.websiteLoaded}
borderColor="border-page-visited"
iconColor="text-page-visited"
percentages={[
{
value: Math.round((result.websiteLoaded / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
@@ -1680,11 +1727,47 @@
</svg>
</StatsCard>
<StatsCard
title="Data Submitted"
value={result.submittedData}
borderColor="border-submitted-data"
iconColor="text-submitted-data"
{/if}
{#if campaign.isTraining}
<StatsCard
title="In progress"
value={Math.max(result.trainingStarted - result.trainingCompleted, 0)}
borderColor="border-training-started"
iconColor="text-training-started"
percentages={[
{
value: Math.round(
((result.trainingStarted - result.trainingCompleted) / result.trainingStarted) *
100
),
relativeTo: 'of started',
baseValue: result.trainingStarted
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Data Submitted"
value={result.submittedData}
borderColor="border-submitted-data"
iconColor="text-submitted-data"
percentages={[
{
value: Math.round((result.submittedData / result.recipients) * 100),
@@ -1725,11 +1808,59 @@
</svg>
</StatsCard>
<StatsCard
title="Reported"
value={result.reported}
borderColor="border-reported"
iconColor="text-reported"
{/if}
{#if campaign.isTraining}
<StatsCard
title="Training Completed"
value={result.trainingCompleted}
borderColor="border-training-completed"
iconColor="text-training-completed"
percentages={[
{
value: Math.round((result.trainingCompleted / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.trainingCompleted / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.trainingCompleted / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
},
{
value: Math.round((result.trainingCompleted / result.trainingStarted) * 100),
relativeTo: 'of started',
baseValue: result.trainingStarted
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Reported"
value={result.reported}
borderColor="border-reported"
iconColor="text-reported"
percentages={[
{
value: Math.round((result.reported / result.recipients) * 100),
@@ -1769,6 +1900,7 @@
/>
</svg>
</StatsCard>
{/if}
</div>
<div class=" mb-6">
<SubHeadline>Event Timeline</SubHeadline>
+38 -5
View File
@@ -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 @@
<SubHeadline>{contextCompanyName}</SubHeadline>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-4">
<a href="/dashboard/campaigns">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8 mt-4">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Active campaigns"
value={active}
@@ -278,7 +286,7 @@
</StatsCard>
</a>
<a href="/dashboard/campaigns">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Upcoming campaigns"
value={scheduled}
@@ -303,7 +311,7 @@
</StatsCard>
</a>
<a href="/dashboard/campaigns">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Completed campaigns"
value={finished}
@@ -328,7 +336,7 @@
</StatsCard>
</a>
<a href="/recipient">
<a href="/recipient" class="block h-full">
<StatsCard
title="Repeat offenders"
value={repeatOffenders}
@@ -352,6 +360,31 @@
</svg>
</StatsCard>
</a>
<a href="/campaign" class="block h-full">
<StatsCard
title="Trainings completed"
value={trainingCompleted}
borderColor="border-training-completed"
iconColor="text-training-completed"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
/>
</svg>
</StatsCard>
</a>
</div>
<SubHeadline>{contextCompanyName ? 'Campaign Trends' : 'Shared Campaign Trends'}</SubHeadline>
@@ -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}
<BigButton on:click={onClickExport}>Export events</BigButton>
<div>
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-7 gap-6 mb-8 mt-4">
<div class="grid grid-cols-1 lg:grid-cols-7 gap-6 mb-8 mt-4">
<!-- Campaigns card -->
<StatsCard
title="Campaigns"
@@ -333,6 +335,14 @@
</svg>
</StatsCard>
</div>
{#if stats.trainingsAssigned > 0}
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mb-8 text-sm">
<span class="font-semibold text-grayblue-dark dark:text-gray-400">Training</span>
<span class="text-grayblue-dark dark:text-gray-400">Assigned <span class="font-semibold text-pc-darkblue dark:text-gray-200">{stats.trainingsAssigned}</span></span>
<span class="text-grayblue-dark dark:text-gray-400">Completed <span class="font-semibold text-training-completed">{stats.trainingsCompleted}</span></span>
</div>
{/if}
<SubHeadline>Groups</SubHeadline>
<Table
columns={['Name']}
@@ -30,6 +30,9 @@
let reportTemplateID = null;
let reportTemplateError = '';
let isReportTemplateSubmitting = false;
// the phishing and training reports are separate templates; edit one at a time
let reportRows = [];
let reportKind = 'phishing'; // 'phishing' | 'training'
onMount(async () => {
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 @@
<div
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<div class="flex items-center gap-2 mb-4">
<span class="text-sm font-semibold text-gray-600 dark:text-gray-300">Report for</span>
<div
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-sm"
>
<button
type="button"
class="px-3 py-1 transition-colors duration-200 {reportKind === 'phishing'
? 'bg-cta-blue text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
on:click={() => selectReportKind('phishing')}
>
Phishing
</button>
<button
type="button"
class="px-3 py-1 transition-colors duration-200 {reportKind === 'training'
? 'bg-training-completed text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
on:click={() => selectReportKind('training')}
>
Training
</button>
</div>
</div>
<Editor contentType="report" bind:value={reportTemplateContent} />
<FormError message={reportTemplateError} />
</div>
+2
View File
@@ -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',