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
-
+
+ + + + + + +