mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 08:00:34 +02:00
added awareness training campaign mode, report, template mode, and stats
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
+139
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;"`
|
||||
|
||||
@@ -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,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>
|
||||
@@ -22,3 +22,6 @@ var SigningKey2 []byte
|
||||
|
||||
//go:embed default_report.html
|
||||
var DefaultReportHTML string
|
||||
|
||||
//go:embed default_training_report.html
|
||||
var DefaultTrainingReportHTML string
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user