From 854e0243d02826422c3b522d22d509b4e471e64d Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Wed, 17 Sep 2025 19:52:40 +0200
Subject: [PATCH] Add reported functionality
Signed-off-by: Ronni Skansing
---
backend/app/administration.go | 2 +
backend/cache/local.go | 1 +
backend/controller/campaign.go | 69 +++++
backend/data/events.go | 2 +
backend/database/campaignStats.go | 2 +
backend/model/campaignResultView.go | 1 +
backend/model/recipientCampaignStatsView.go | 1 +
backend/repository/campaign.go | 26 ++
backend/repository/recipient.go | 12 +
backend/service/campaign.go | 267 +++++++++++++++++-
backend/testfiles/reporters.csv | 2 +
.../lib/components/CampaignTrendChart.svelte | 39 ++-
frontend/src/lib/utils/events.js | 5 +
.../src/routes/campaign/[id]/+page.svelte | 196 ++++++++++---
frontend/src/routes/dashboard/+page.svelte | 2 +
.../src/routes/recipient/[id]/+page.svelte | 29 +-
frontend/tailwind.config.js | 22 +-
17 files changed, 619 insertions(+), 59 deletions(-)
create mode 100644 backend/testfiles/reporters.csv
diff --git a/backend/app/administration.go b/backend/app/administration.go
index 81c039a..5a37b1a 100644
--- a/backend/app/administration.go
+++ b/backend/app/administration.go
@@ -132,6 +132,7 @@ const (
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all"
+ ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported"
// campaign-recipient
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
@@ -364,6 +365,7 @@ func setupRoutes(
POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID).
GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV).
GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV).
+ POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV).
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID).
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
// campaign-recipient
diff --git a/backend/cache/local.go b/backend/cache/local.go
index a123c5c..4b5bf09 100644
--- a/backend/cache/local.go
+++ b/backend/cache/local.go
@@ -37,6 +37,7 @@ func IsUpdateAvailable() bool {
// readonly
var CampaignEventPriority = map[string]int{
// campaign recipient events
+ data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90,
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60,
diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go
index 1e66399..ee2c6cc 100644
--- a/backend/controller/campaign.go
+++ b/backend/controller/campaign.go
@@ -3,6 +3,8 @@ package controller
import (
"bytes"
"encoding/csv"
+ "io"
+ "strings"
"time"
"github.com/go-errors/errors"
@@ -935,3 +937,70 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
}
c.Response.OK(g, stats)
}
+
+// UploadReportedCSV uploads a CSV file with reported recipients
+func (c *Campaign) UploadReportedCSV(g *gin.Context) {
+ // handle session
+ session, _, ok := c.handleSession(g)
+ if !ok {
+ return
+ }
+ // parse campaign id
+ id, ok := c.handleParseIDParam(g)
+ if !ok {
+ return
+ }
+
+ // get the uploaded file
+ file, header, err := g.Request.FormFile("file")
+ if err != nil {
+ c.Response.ValidationFailed(g, "file", err)
+ return
+ }
+ defer file.Close()
+
+ // validate file extension
+ if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") {
+ c.Response.ValidationFailed(g, "file", errors.New("file must be a CSV"))
+ return
+ }
+
+ // read file content
+ content, err := io.ReadAll(file)
+ if err != nil {
+ c.Response.ValidationFailed(g, "file", err)
+ return
+ }
+
+ // parse CSV
+ reader := csv.NewReader(strings.NewReader(string(content)))
+ records, err := reader.ReadAll()
+ if err != nil {
+ c.Logger.Errorw("failed to parse CSV file", "error", err)
+ c.Response.ValidationFailed(g, "file", errors.New("failed to parse CSV file: "+err.Error()))
+ return
+ }
+
+ if len(records) < 2 {
+ c.Logger.Debugw("CSV file has insufficient rows", "rows", len(records))
+ c.Response.ValidationFailed(g, "file", errors.New("CSV file must have header and at least one data row"))
+ return
+ }
+
+ c.Logger.Debugw("processing CSV", "rows", len(records), "headers", records[0])
+
+ // process CSV
+ processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records)
+ if err != nil {
+ c.Logger.Errorw("failed to process reported CSV", "error", err)
+ if ok := c.handleErrors(g, err); !ok {
+ return
+ }
+ }
+
+ c.Response.OK(g, gin.H{
+ "processed": processed,
+ "skipped": skipped,
+ "message": "CSV processed successfully",
+ })
+}
diff --git a/backend/data/events.go b/backend/data/events.go
index b01ca8c..e04950a 100644
--- a/backend/data/events.go
+++ b/backend/data/events.go
@@ -14,6 +14,7 @@ const (
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited"
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited"
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data"
+ EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported"
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
)
@@ -32,5 +33,6 @@ var Events = []string{
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
+ EVENT_CAMPAIGN_RECIPIENT_REPORTED,
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
}
diff --git a/backend/database/campaignStats.go b/backend/database/campaignStats.go
index 965dc1b..2bc4747 100644
--- a/backend/database/campaignStats.go
+++ b/backend/database/campaignStats.go
@@ -35,11 +35,13 @@ type CampaignStats struct {
TrackingPixelLoaded int `gorm:"not null;default:0" json:"trackingPixelLoaded"` // Email opens
WebsiteVisits int `gorm:"not null;default:0" json:"websiteVisits"` // Link clicks
DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions
+ Reported int `gorm:"not null;default:0" json:"reported"` // Reported phishing
// Success rates (as percentages for quick display)
OpenRate float64 `gorm:"not null;default:0" json:"openRate"`
ClickRate float64 `gorm:"not null;default:0" json:"clickRate"`
SubmissionRate float64 `gorm:"not null;default:0" json:"submissionRate"`
+ ReportRate float64 `gorm:"not null;default:0" json:"reportRate"`
// Campaign metadata
TemplateName string `gorm:"" json:"templateName"`
diff --git a/backend/model/campaignResultView.go b/backend/model/campaignResultView.go
index 938e338..3a4d291 100644
--- a/backend/model/campaignResultView.go
+++ b/backend/model/campaignResultView.go
@@ -6,4 +6,5 @@ type CampaignResultView struct {
TrackingPixelLoaded int64 `json:"trackingPixelLoaded"`
WebsiteLoaded int64 `json:"clickedLink"`
SubmittedData int64 `json:"submittedData"`
+ Reported int64 `json:"reported"`
}
diff --git a/backend/model/recipientCampaignStatsView.go b/backend/model/recipientCampaignStatsView.go
index d40d975..048edee 100644
--- a/backend/model/recipientCampaignStatsView.go
+++ b/backend/model/recipientCampaignStatsView.go
@@ -5,6 +5,7 @@ type RecipientCampaignStatsView struct {
CampaignsTrackingPixelLoaded int64 `json:"campaignsTrackingPixelLoaded"`
CampaignsPhishingPageLoaded int64 `json:"campaignsPhishingPageLoaded"`
CampaignsDataSubmitted int64 `json:"campaignsDataSubmitted"`
+ CampaignsReported int64 `json:"campaignsReported"`
RepeatLinkClicks int64 `json:"repeatLinkClicks"`
RepeatSubmissions int64 `json:"repeatSubmissions"`
}
diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go
index 773c0dc..5b1c567 100644
--- a/backend/repository/campaign.go
+++ b/backend/repository/campaign.go
@@ -794,6 +794,32 @@ func (r *Campaign) GetResultStats(
return nil, res.Error
}
+ // Get unique reported
+ 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_REPORTED],
+ campaignID,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
+ ).Scan(&stats.Reported)
+
+ if res.Error != nil {
+ return nil, res.Error
+ }
+
return stats, nil
}
diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go
index f89a875..542904a 100644
--- a/backend/repository/recipient.go
+++ b/backend/repository/recipient.go
@@ -419,6 +419,18 @@ func (r *Recipient) GetStatsByID(
Distinct("campaign_events.campaign_id").
Count(&stats.CampaignsDataSubmitted)
+ // get unique reported campaigns
+ 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 = ?",
+ id,
+ cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
+ false,
+ ).
+ Distinct("campaign_events.campaign_id").
+ Count(&stats.CampaignsReported)
+
// Get repeat link clicks in last selected threshold months
var linkClickCount int64
r.DB.Model(&database.CampaignEvent{}).
diff --git a/backend/service/campaign.go b/backend/service/campaign.go
index 52da103..c2398c0 100644
--- a/backend/service/campaign.go
+++ b/backend/service/campaign.go
@@ -766,7 +766,6 @@ func (c *Campaign) GetStats(
if err != nil {
return nil, errs.Wrap(err)
}
- // no audit on read
return &model.CampaignsStatView{
Active: active,
Upcoming: upcoming,
@@ -3051,11 +3050,13 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
openRate := float64(0)
clickRate := float64(0)
submissionRate := float64(0)
+ reportRate := float64(0)
if resultStats.Recipients > 0 {
openRate = (float64(resultStats.TrackingPixelLoaded) / float64(resultStats.Recipients)) * 100
clickRate = (float64(resultStats.WebsiteLoaded) / float64(resultStats.Recipients)) * 100
submissionRate = (float64(resultStats.SubmittedData) / float64(resultStats.Recipients)) * 100
+ reportRate = (float64(resultStats.Reported) / float64(resultStats.Recipients)) * 100
}
// Determine campaign type
@@ -3116,9 +3117,11 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
TrackingPixelLoaded: int(resultStats.TrackingPixelLoaded),
WebsiteVisits: int(resultStats.WebsiteLoaded),
DataSubmissions: int(resultStats.SubmittedData),
+ Reported: int(resultStats.Reported),
OpenRate: openRate,
ClickRate: clickRate,
SubmissionRate: submissionRate,
+ ReportRate: reportRate,
TemplateName: templateName,
CampaignType: campaignType,
@@ -3184,3 +3187,265 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
return result, nil
}
+
+// ProcessReportedCSV processes a CSV file with reported recipients
+func (c *Campaign) ProcessReportedCSV(
+ ctx context.Context,
+ session *model.Session,
+ campaignID *uuid.UUID,
+ records [][]string,
+) (int, int, error) {
+ ae := NewAuditEvent("Campaign.ProcessReportedCSV", session)
+ ae.Details["campaignID"] = campaignID.String()
+
+ // check permissions
+ isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
+ if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
+ c.LogAuthError(err)
+ return 0, 0, errs.Wrap(err)
+ }
+ if !isAuthorized {
+ c.AuditLogNotAuthorized(ae)
+ return 0, 0, errs.ErrAuthorizationFailed
+ }
+
+ // get campaign to check it exists and get details
+ campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
+ if err != nil {
+ c.Logger.Errorw("failed to get campaign by id", "error", err)
+ return 0, 0, errs.Wrap(err)
+ }
+
+ // validate CSV headers
+ headers := records[0]
+ reportedByIndex := -1
+ dateReportedIndex := -1
+
+ c.Logger.Debugw("processing CSV headers", "headers", headers)
+
+ for i, header := range headers {
+ switch strings.ToLower(strings.TrimSpace(header)) {
+ case "reported by":
+ reportedByIndex = i
+ c.Logger.Debugw("found reported by column", "index", i)
+ case "date reporter (utc+02:00)", "date reported(utc+02:00)", "date reported", "date reporter":
+ dateReportedIndex = i
+ c.Logger.Debugw("found date column", "index", i, "header", header)
+ }
+ }
+
+ if reportedByIndex == -1 {
+ c.Logger.Errorw("CSV missing required column", "expected", "reported by", "headers", headers)
+ return 0, 0, errs.NewValidationError(errors.New("CSV must have 'reported by' column"))
+ }
+ if dateReportedIndex == -1 {
+ c.Logger.Errorw("CSV missing required date column", "expected", "date reported(utc+02:00)", "headers", headers)
+ return 0, 0, errs.NewValidationError(errors.New("CSV must have 'date reporter (utc+02:00)' or similar date column"))
+ }
+
+ processed := 0
+ skipped := 0
+ reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
+
+ // process each row
+ for i, record := range records[1:] { // skip header
+ if len(record) <= reportedByIndex || len(record) <= dateReportedIndex {
+ skipped++
+ c.Logger.Debugw("skipping row with insufficient columns", "row", i+2)
+ continue
+ }
+
+ reportedByEmail := strings.TrimSpace(record[reportedByIndex])
+ dateReported := strings.TrimSpace(record[dateReportedIndex])
+
+ if reportedByEmail == "" {
+ skipped++
+ c.Logger.Debugw("skipping row with empty email", "row", i+2)
+ continue
+ }
+ // parse date - try multiple formats and handle timezone
+ var parsedDate time.Time
+ dateFormats := []string{
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02T15:04:05-07:00",
+ "2006-01-02T15:04:05+02:00",
+ "2006-01-02",
+ "01/02/2006 15:04:05",
+ "01/02/2006",
+ "02-01-2006 15:04:05",
+ "02-01-2006",
+ }
+
+ dateParseError := true
+ for _, format := range dateFormats {
+ if pd, err := time.Parse(format, dateReported); err == nil {
+ // if the parsed date has no timezone info and the header mentions UTC+02:00,
+ // assume the time is in UTC+02:00 and convert to UTC
+ if pd.Location() == time.UTC && strings.Contains(strings.ToLower(headers[dateReportedIndex]), "utc+02:00") {
+ // treat as UTC+02:00 and convert to UTC
+ loc, _ := time.LoadLocation("Europe/Berlin") // UTC+2 (or use FixedZone)
+ if loc != nil {
+ pd = time.Date(pd.Year(), pd.Month(), pd.Day(), pd.Hour(), pd.Minute(), pd.Second(), pd.Nanosecond(), loc).UTC()
+ }
+ }
+ parsedDate = pd
+ dateParseError = false
+ break
+ }
+ }
+
+ if dateParseError {
+ skipped++
+ c.Logger.Debugw("skipping row with invalid date format", "row", i+2, "date", dateReported, "tried_formats", dateFormats)
+ continue
+ }
+
+ c.Logger.Debugw("processing row", "row", i+2, "email", reportedByEmail, "date", parsedDate)
+
+ // find recipient by email in this campaign
+ emailVO, err := vo.NewEmail(reportedByEmail)
+ if err != nil {
+ skipped++
+ c.Logger.Debugw("invalid email format", "email", reportedByEmail, "row", i+2)
+ continue
+ }
+
+ // Get campaign to check company context
+ companyID, _ := campaign.CompanyID.Get()
+ var companyPtr *uuid.UUID
+ if companyID != uuid.Nil {
+ companyPtr = &companyID
+ }
+
+ recipient, err := c.RecipientService.GetByEmail(ctx, session, emailVO, companyPtr)
+ if err != nil {
+ skipped++
+ c.Logger.Debugw("recipient not found for email", "email", reportedByEmail, "row", i+2)
+ continue
+ }
+
+ recipientID := recipient.ID.MustGet()
+
+ // check if recipient is part of this campaign
+ campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
+ ctx,
+ campaignID,
+ &recipientID,
+ &repository.CampaignRecipientOption{},
+ )
+ if err != nil {
+ skipped++
+ c.Logger.Debugw("recipient not part of campaign", "email", reportedByEmail, "campaignID", campaignID.String(), "row", i+2)
+ continue
+ }
+
+ // check if already reported (to avoid duplicates)
+ existingEvent, err := c.CampaignRepository.GetEventsByCampaignID(
+ ctx,
+ campaignID,
+ &repository.CampaignEventOption{
+ QueryArgs: &vo.QueryArgs{
+ Limit: 1,
+ },
+ EventTypeIDs: []string{reportedEventID.String()},
+ },
+ nil,
+ )
+
+ alreadyReported := false
+ if err == nil && existingEvent != nil {
+ for _, event := range existingEvent.Rows {
+ if event.RecipientID != nil && *event.RecipientID == recipientID {
+ alreadyReported = true
+ break
+ }
+ }
+ }
+
+ if alreadyReported {
+ skipped++
+ c.Logger.Debugw("recipient already reported", "email", reportedByEmail, "campaignID", campaignID.String())
+ continue
+ }
+
+ // create campaign event for reported
+ eventID := uuid.New()
+
+ var campaignEvent *model.CampaignEvent
+ if campaign.IsAnonymous.MustGet() {
+ campaignEvent = &model.CampaignEvent{
+ ID: &eventID,
+ CampaignID: campaignID,
+ RecipientID: nil,
+ IP: vo.NewEmptyOptionalString64(),
+ UserAgent: vo.NewEmptyOptionalString255(),
+ EventID: reportedEventID,
+ Data: vo.NewEmptyOptionalString1MB(),
+ }
+ } else {
+ campaignEvent = &model.CampaignEvent{
+ ID: &eventID,
+ CampaignID: campaignID,
+ RecipientID: &recipientID,
+ IP: vo.NewEmptyOptionalString64(),
+ UserAgent: vo.NewEmptyOptionalString255(),
+ EventID: reportedEventID,
+ Data: vo.NewEmptyOptionalString1MB(),
+ }
+ }
+
+ // save the event with custom timestamp
+ err = c.saveReportedEvent(campaignEvent, parsedDate)
+ if err != nil {
+ c.Logger.Errorw("failed to save reported event", "error", err, "email", reportedByEmail)
+ skipped++
+ continue
+ }
+
+ // update most notable event for campaign recipient
+ err = c.SetNotableCampaignRecipientEvent(
+ ctx,
+ campaignRecipient,
+ data.EVENT_CAMPAIGN_RECIPIENT_REPORTED,
+ )
+ if err != nil {
+ c.Logger.Errorw("failed to update notable event", "error", err)
+ }
+
+ processed++
+ }
+
+ ae.Details["processed"] = processed
+ ae.Details["skipped"] = skipped
+ c.AuditLogAuthorized(ae)
+
+ return processed, skipped, nil
+}
+
+// saveReportedEvent saves a reported event with custom timestamp
+func (c *Campaign) saveReportedEvent(
+ campaignEvent *model.CampaignEvent,
+ customTime time.Time,
+) error {
+ row := map[string]any{
+ "id": campaignEvent.ID.String(),
+ "event_id": campaignEvent.EventID.String(),
+ "campaign_id": campaignEvent.CampaignID.String(),
+ "ip_address": campaignEvent.IP.String(),
+ "user_agent": campaignEvent.UserAgent.String(),
+ "data": campaignEvent.Data.String(),
+ "created_at": customTime,
+ "updated_at": time.Now(),
+ }
+ if campaignEvent.RecipientID != nil {
+ row["recipient_id"] = campaignEvent.RecipientID.String()
+ }
+
+ res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row)
+ if res.Error != nil {
+ return res.Error
+ }
+ return nil
+}
diff --git a/backend/testfiles/reporters.csv b/backend/testfiles/reporters.csv
new file mode 100644
index 0000000..9f51e00
--- /dev/null
+++ b/backend/testfiles/reporters.csv
@@ -0,0 +1,2 @@
+Reported by,Date reported(UTC+02:00)
+alice@black-boat.test,2025-09-17T20:11:24
diff --git a/frontend/src/lib/components/CampaignTrendChart.svelte b/frontend/src/lib/components/CampaignTrendChart.svelte
index be3bae4..2c22f40 100644
--- a/frontend/src/lib/components/CampaignTrendChart.svelte
+++ b/frontend/src/lib/components/CampaignTrendChart.svelte
@@ -85,8 +85,10 @@
openRate: true,
clickRate: true,
submissionRate: true,
+ reportRate: true,
'mavg-clickRate': true,
- 'mavg-submissionRate': true
+ 'mavg-submissionRate': true,
+ 'mavg-reportRate': true
};
// Responsive margins based on container width
@@ -138,7 +140,8 @@
const metrics = [
{ 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: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' },
+ { key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' }
];
// Toggle metric visibility
@@ -159,7 +162,8 @@
n,
openRate: avg(slice, 'openRate'),
clickRate: avg(slice, 'clickRate'),
- submissionRate: avg(slice, 'submissionRate')
+ submissionRate: avg(slice, 'submissionRate'),
+ reportRate: avg(slice, 'reportRate')
};
})();
@@ -205,6 +209,7 @@
clickRate: Math.round((stat.clickRate || 0) * (stat.clickRate > 1 ? 1 : 100) * 10) / 10,
submissionRate:
Math.round((stat.submissionRate || 0) * (stat.submissionRate > 1 ? 1 : 100) * 10) / 10,
+ reportRate: Math.round((stat.reportRate || 0) * (stat.reportRate > 1 ? 1 : 100) * 10) / 10,
totalRecipients: stat.totalRecipients
}));
}
@@ -247,8 +252,8 @@
createLine(svg, metric);
}
});
- // Only draw moving average for clickRate and submissionRate, using user-selected N
- ['clickRate', 'submissionRate'].forEach((metricKey) => {
+ // Only draw moving average for clickRate, submissionRate, and reportRate, using user-selected N
+ ['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => {
const metric = metrics.find((m) => m.key === metricKey);
if (metric && visibleMetrics[`mavg-${metricKey}`]) {
createMovingAverageLine(svg, metric, movingAvgN);
@@ -309,6 +314,8 @@
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
}
path.setAttribute('stroke', avgColor);
path.setAttribute('stroke-width', '1.2');
@@ -545,18 +552,28 @@
strokeDasharray: null,
opacity: 1
});
- if (metric.key === 'clickRate' || metric.key === 'submissionRate') {
+ 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';
}
legendItems.push({
type: 'mavg',
key: metric.key,
- label: metric.key === 'clickRate' ? 'Click MA' : 'Submit MA',
+ label: avgLabel,
color: avgColor,
class: `legend-line legend-mavg legend-mavg-${metric.key}`,
labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`,
@@ -863,7 +880,7 @@
-
+
{chartData[0].openRate}%
Open Rate
@@ -876,6 +893,10 @@
{chartData[0].submissionRate}%
Submission Rate
+
+
{chartData[0].reportRate}%
+
Report Rate
+
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length >= 2}
@@ -926,7 +947,7 @@
{#if chartData.length > 0}
-
+
{#each metrics as metric}
diff --git a/frontend/src/lib/utils/events.js b/frontend/src/lib/utils/events.js
index 0d553e3..24235c7 100644
--- a/frontend/src/lib/utils/events.js
+++ b/frontend/src/lib/utils/events.js
@@ -26,6 +26,11 @@ const eventNameMap = {
priority: 90,
color: 'bg-submitted-data'
},
+ campaign_recipient_reported: {
+ name: 'Reported',
+ priority: 95,
+ color: 'bg-reported'
+ },
// campaign events
campaign_scheduled: { name: 'Scheduled', priority: 10 },
campaign_active: { name: 'Active', priority: 20 },
diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte
index 4f0c265..aae61c1 100644
--- a/frontend/src/routes/campaign/[id]/+page.svelte
+++ b/frontend/src/routes/campaign/[id]/+page.svelte
@@ -83,7 +83,8 @@
emailsSent: 0,
trackingPixelLoaded: 0,
websiteLoaded: 0,
- submittedData: 0
+ submittedData: 0,
+ reported: 0
};
// @ts-ignore
const recipientTableUrlParams = newTableURLParams({
@@ -340,6 +341,7 @@
result.trackingPixelLoaded = res.data.trackingPixelLoaded;
result.websiteLoaded = res.data.clickedLink;
result.submittedData = res.data.submittedData;
+ result.reported = res.data.reported;
} catch (e) {
addToast('Failed to load campaign result stats', 'Error');
console.error('failed to load campaign result stats', e);
@@ -685,6 +687,54 @@
const onClickUpdateCampaign = () => {
goto(`/campaign?update=${$page.params.id}`);
};
+
+ const onUploadReportedCSV = async (event) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ // validate file type
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ addToast('Please select a CSV file', 'Error');
+ event.target.value = '';
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ showIsLoading();
+ const response = await fetch(`/api/v1/campaign/${$page.params.id}/upload/reported`, {
+ method: 'POST',
+ body: formData,
+ credentials: 'include'
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.success) {
+ addToast(
+ `Successfully processed ${result.data.processed} reported entries${result.data.skipped > 0 ? `, skipped ${result.data.skipped} invalid entries` : ''}`,
+ 'Success'
+ );
+ // refresh the stats, events, and recipients table
+ await setResults();
+ await refreshCampaignRecipients();
+ await getEvents();
+ } else {
+ // handle validation errors
+ const errorMessage = result.error || `HTTP ${response.status}`;
+ addToast(`Upload failed: ${errorMessage}`, 'Error');
+ }
+ } catch (error) {
+ console.error('Upload error:', error);
+ addToast('Network error: Failed to upload CSV', 'Error');
+ } finally {
+ hideIsLoading();
+ // clear the file input
+ event.target.value = '';
+ }
+ };
@@ -724,7 +774,9 @@
/>
-
+
Event Timeline
@@ -1092,45 +1189,74 @@
-
+
Actions
-
- {#if !campaignUpdateDisabledAndTitle(campaign).disabled}
+
+
+
+ {#if !campaignUpdateDisabledAndTitle(campaign).disabled}
+
+ {/if}
- {/if}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CSV format: "Reported by" (email), "Date reported(UTC+02:00)"
+
+
+
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte
index 3cf7742..ba185ed 100644
--- a/frontend/src/routes/dashboard/+page.svelte
+++ b/frontend/src/routes/dashboard/+page.svelte
@@ -442,6 +442,7 @@
{ column: 'Open Rate', size: 'small' },
{ column: 'Click Rate', size: 'small' },
{ column: 'Submission Rate', size: 'small' },
+ { column: 'Report Rate', size: 'small' },
{ column: 'Closed', size: 'small' }
]}
hasData={!!campaignStats.length}
@@ -460,6 +461,7 @@
+
{/each}
diff --git a/frontend/src/routes/recipient/[id]/+page.svelte b/frontend/src/routes/recipient/[id]/+page.svelte
index 478de9c..78c5096 100644
--- a/frontend/src/routes/recipient/[id]/+page.svelte
+++ b/frontend/src/routes/recipient/[id]/+page.svelte
@@ -37,7 +37,8 @@
campaignsParticiated: 0,
campaignsTrackingPixelLoaded: 0,
campaignsPhishingPageLoaded: 0,
- campaignsDataSubmitted: 0
+ campaignsDataSubmitted: 0,
+ campaignsReported: 0
};
let isGroupsLoading = false;
let isEventsLoading = false;
@@ -155,7 +156,7 @@
{/if}
Export events