Add reported functionality

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-09-17 19:52:40 +02:00
parent c1709c24aa
commit 854e0243d0
17 changed files with 619 additions and 59 deletions
+2
View File
@@ -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
+1
View File
@@ -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,
+69
View File
@@ -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",
})
}
+2
View File
@@ -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,
}
+2
View File
@@ -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"`
+1
View File
@@ -6,4 +6,5 @@ type CampaignResultView struct {
TrackingPixelLoaded int64 `json:"trackingPixelLoaded"`
WebsiteLoaded int64 `json:"clickedLink"`
SubmittedData int64 `json:"submittedData"`
Reported int64 `json:"reported"`
}
@@ -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"`
}
+26
View File
@@ -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
}
+12
View File
@@ -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{}).
+266 -1
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
Reported by,Date reported(UTC+02:00)
alice@black-boat.test,2025-09-17T20:11:24
1 Reported by Date reported(UTC+02:00)
2 alice@black-boat.test 2025-09-17T20:11:24
@@ -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 @@
</p>
</div>
</div>
<div class="mt-4 grid grid-cols-3 gap-4">
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{chartData[0].openRate}%</div>
<div class="text-sm text-gray-600">Open Rate</div>
@@ -876,6 +893,10 @@
<div class="text-2xl font-bold text-yellow-600">{chartData[0].submissionRate}%</div>
<div class="text-sm text-gray-600">Submission Rate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-indigo-600">{chartData[0].reportRate}%</div>
<div class="text-sm text-gray-600">Report Rate</div>
</div>
</div>
</div>
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length >= 2}
@@ -926,7 +947,7 @@
</div>
<div style="height: 1.25rem;"></div>
{#if chartData.length > 0}
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<div class="grid grid-cols-4 gap-2 sm:gap-4">
{#each metrics as metric}
<div class="text-center">
<div class="flex items-center justify-center">
+5
View File
@@ -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 },
+161 -35
View File
@@ -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 = '';
}
};
</script>
<HeadTitle title="Campaign {campaign.name ? ` - ${campaign.name}` : ''}" />
@@ -724,7 +774,9 @@
/>
</div>
<div class="grid grid-row-1 grid-cols-1 md:grid-cols-2 gap-6 mb-8 mt-4 lg:grid-cols-5">
<div
class="grid grid-row-1 grid-cols-1 md:grid-cols-2 gap-6 mb-8 mt-4 lg:grid-cols-3 2xl:grid-cols-6"
>
<StatsCard
title="Recipients"
value={result.recipients}
@@ -902,6 +954,51 @@
/>
</svg>
</StatsCard>
<StatsCard
title="Reported"
value={result.reported}
borderColor="border-reported"
iconColor="text-reported"
percentages={[
{
value: Math.round((result.reported / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.reported / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.reported / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
},
{
value: Math.round((result.reported / result.websiteLoaded) * 100),
relativeTo: 'of visits',
baseValue: result.websiteLoaded
}
]}
>
<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"
>
<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>
</StatsCard>
</div>
<div class=" mb-6">
<SubHeadline>Event Timeline</SubHeadline>
@@ -1092,45 +1189,74 @@
</div>
</div>
<!-- Second Row: Schedule Constraints and Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div></div>
<div class="bg-white p-6 rounded-lg">
<h3 class="text-xl font-semibold text-pc-darkblue mb-4 border-b pb-2">Actions</h3>
<div class="flex flex-wrap gap-4">
{#if !campaignUpdateDisabledAndTitle(campaign).disabled}
<div class="space-y-4">
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3">
{#if !campaignUpdateDisabledAndTitle(campaign).disabled}
<button
on:click={onClickUpdateCampaign}
class="px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:opacity-80 text-white rounded-md transition-colors"
>
Update
</button>
{/if}
<button
on:click={onClickUpdateCampaign}
class="px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:opacity-80 text-white rounded-md transition-colors"
on:click={showCloseCampaignModal}
disabled={!!campaign.closedAt}
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Update
Close
</button>
{/if}
<button
on:click={showCloseCampaignModal}
disabled={!!campaign.closedAt}
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Close
</button>
<button
on:click={showAnonymizeModal}
disabled={!!campaign.anonymizedAt}
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Anonymize
</button>
<button
on:click={onClickExportEvents}
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Export events
</button>
<button
on:click={onClickExportSubmissions}
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Export submitters
</button>
<button
on:click={showAnonymizeModal}
disabled={!!campaign.anonymizedAt}
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Anonymize
</button>
</div>
<!-- Export Buttons -->
<div class="flex flex-wrap gap-3">
<button
on:click={onClickExportEvents}
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Export events
</button>
<button
on:click={onClickExportSubmissions}
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
>
Export submitters
</button>
</div>
<!-- Upload Section -->
<div class="border-t pt-4">
<div>
<label
for="reported-csv-upload"
class="block text-sm font-medium text-gray-700 mb-2"
>
Upload Reported CSV
</label>
<input
type="file"
id="reported-csv-upload"
accept=".csv"
on:change={onUploadReportedCSV}
class="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-reported file:text-white hover:file:opacity-80"
/>
<p class="mt-1 text-xs text-gray-500">
CSV format: "Reported by" (email), "Date reported(UTC+02:00)"
</p>
</div>
</div>
</div>
</div>
</div>
@@ -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 @@
<TableCell value="{Math.round(stat.openRate)}%" />
<TableCell value="{Math.round(stat.clickRate)}%" />
<TableCell value="{Math.round(stat.submissionRate)}%" />
<TableCell value="{Math.round(stat.reportRate)}%" />
<TableCell value={stat.campaignClosedAt} isDate isRelative />
</TableRow>
{/each}
@@ -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}
<BigButton on:click={onClickExport}>Export events</BigButton>
<div>
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-6 gap-6 mb-8 mt-4">
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-7 gap-6 mb-8 mt-4">
<!-- Campaigns card -->
<StatsCard
title="Campaigns"
@@ -258,6 +259,30 @@
</svg>
</StatsCard>
<!-- Reported card -->
<StatsCard
title="Reported"
value={stats.campaignsReported}
borderColor="border-reported"
iconColor="text-reported"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2 text-reported"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
</StatsCard>
<!-- Repeat link clicks card -->
<StatsCard
title="Repeat link clicks"
+10 -12
View File
@@ -30,16 +30,17 @@ export default {
'pc-lightblue': '#ddddff',
'pc-green': '#5dd8c4ff',
'pc-dusty-light-blue': '#819efb',
'scheduled': '#e756f6',
scheduled: '#e756f6',
'recipient-scheduled': '#4e68d8',
'failed-sending': '#f2bb58',
'cancelled': '#161692',
cancelled: '#161692',
'message-sent': '#94cae6',
'message-read': '#4cb5b5',
'before-page-visited': '#eea5fa',
'page-visited': '#f96dcf',
'after-page-visited': '#f6287b',
'submitted-data': '#f42e41',
reported: '#2c3e50',
'completed-campaign': '#48bb78',
'repeat-offenders': '#ff6768',
'emails-read': '#e8e810',
@@ -47,15 +48,13 @@ export default {
'repeat-link-clicks': '#c78bfd',
'data-submitted': '#f42e41',
'repeart-submissions': '#ff6a91',
'recipients': '#6cd4cc',
'closed': '#9f9f9f',
recipients: '#6cd4cc',
closed: '#9f9f9f',
'campaign-active': '#5557f6',
'campaign-scheduled':'#62aded',
'campaign-completed':'#69e1ab',
'campaign-scheduled': '#62aded',
'campaign-completed': '#69e1ab'
},
/*
/*
'scheduled':'#e756f6',
'recipient-scheduled':'#4e68d8',
'failed-sending':'#4cb5b5',
@@ -87,7 +86,7 @@ export default {
'67vh': '67vh',
'70vh': '70vh',
'75vh': '75vh',
'80vh': '80vh',
'80vh': '80vh'
},
width: {
'1/10': '10%',
@@ -96,8 +95,7 @@ export default {
'70vw': '70vw',
'80vw': '80vw',
'90vw': '90vw'
},
}
}
}
};