mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-07 04:47:55 +02:00
@@ -144,6 +144,10 @@ 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_STATS_CREATE = "/api/v1/campaign/stats"
|
||||
ROUTE_V1_CAMPAIGN_STATS_MANUAL = "/api/v1/campaign/stats/manual"
|
||||
ROUTE_V1_CAMPAIGN_STATS_UPDATE = "/api/v1/campaign/stats/:id"
|
||||
ROUTE_V1_CAMPAIGN_STATS_DELETE = "/api/v1/campaign/stats/:id"
|
||||
ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported"
|
||||
// campaign-recipient
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
|
||||
@@ -381,6 +385,10 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
|
||||
POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_MANUAL, middleware.SessionHandler, controllers.Campaign.GetManualCampaignStats).
|
||||
PUT(ROUTE_V1_CAMPAIGN_STATS_UPDATE, middleware.SessionHandler, controllers.Campaign.UpdateCampaignStats).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_STATS_DELETE, middleware.SessionHandler, controllers.Campaign.DeleteCampaignStatsManual).
|
||||
GET(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.GetByID).
|
||||
GET(ROUTE_V1_CAMPAIGN_NAME, middleware.SessionHandler, controllers.Campaign.GetByName).
|
||||
POST(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.Create).
|
||||
|
||||
@@ -946,21 +946,112 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
queryArgs, ok := c.handleQueryArgs(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
||||
companyID := companyIDFromRequestQuery(g)
|
||||
|
||||
// get stats
|
||||
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, queryArgs, companyID)
|
||||
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, companyID)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// CreateCampaignStats creates manual campaign statistics
|
||||
func (c *Campaign) CreateCampaignStats(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// parse request body
|
||||
var req database.CampaignStats
|
||||
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
c.Response.BadRequest(g)
|
||||
return
|
||||
}
|
||||
|
||||
// create stats
|
||||
stats, err := c.CampaignService.CreateManualCampaignStats(g.Request.Context(), session, &req)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// GetManualCampaignStats gets all manual campaign statistics (those without campaignID)
|
||||
func (c *Campaign) GetManualCampaignStats(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
companyID := companyIDFromRequestQuery(g)
|
||||
|
||||
// get manual stats (those with null campaignID)
|
||||
stats, err := c.CampaignService.GetManualCampaignStats(g.Request.Context(), session, companyID)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// UpdateCampaignStats updates manual campaign statistics by ID
|
||||
func (c *Campaign) UpdateCampaignStats(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// parse request body
|
||||
var req database.CampaignStats
|
||||
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
c.Response.BadRequest(g)
|
||||
return
|
||||
}
|
||||
|
||||
// update stats
|
||||
req.ID = id
|
||||
stats, err := c.CampaignService.UpdateManualCampaignStats(g.Request.Context(), session, &req)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// DeleteCampaignStatsManual deletes manual campaign statistics by ID
|
||||
func (c *Campaign) DeleteCampaignStatsManual(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// delete manual stats
|
||||
err := c.CampaignService.DeleteManualCampaignStats(g.Request.Context(), session, id)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Response.OK(g, gin.H{"message": "Campaign stats deleted successfully"})
|
||||
}
|
||||
|
||||
// UploadReportedCSV uploads a CSV file with reported recipients
|
||||
func (c *Campaign) UploadReportedCSV(g *gin.Context) {
|
||||
// handle session
|
||||
|
||||
@@ -17,7 +17,7 @@ type CampaignStats struct {
|
||||
UpdatedAt *time.Time `gorm:"not null;" json:"updatedAt"`
|
||||
|
||||
// Campaign reference
|
||||
CampaignID *uuid.UUID `gorm:"not null;unique;index;type:uuid;" json:"campaignId"`
|
||||
CampaignID *uuid.UUID `gorm:"index;type:uuid;" json:"campaignId"`
|
||||
CampaignName string `gorm:"not null;" json:"campaignName"`
|
||||
CompanyID *uuid.UUID `gorm:"index;type:uuid;" json:"companyId"` // nullable for global campaigns
|
||||
|
||||
|
||||
@@ -1666,42 +1666,22 @@ func (r *Campaign) GetCampaignStats(ctx context.Context, campaignID *uuid.UUID)
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetAllCampaignStats retrieves campaign statistics with pagination and filtering
|
||||
func (r *Campaign) GetAllCampaignStats(ctx context.Context, companyID *uuid.UUID, options *vo.QueryArgs) ([]database.CampaignStats, error) {
|
||||
// GetAllCampaignStats retrieves all campaign statistics
|
||||
func (r *Campaign) GetAllCampaignStats(ctx context.Context, companyID *uuid.UUID) ([]database.CampaignStats, error) {
|
||||
var stats []database.CampaignStats
|
||||
|
||||
db := r.DB.WithContext(ctx)
|
||||
db := r.DB.WithContext(ctx).Order("created_at DESC")
|
||||
|
||||
if companyID != nil {
|
||||
db = db.Where("company_id = ?", companyID)
|
||||
}
|
||||
|
||||
if options != nil {
|
||||
if options.Search != "" {
|
||||
db = db.Where("campaign_name ILIKE ?", "%"+options.Search+"%")
|
||||
}
|
||||
|
||||
if options.OrderBy != "" {
|
||||
sortColumn := options.OrderBy
|
||||
if options.Desc {
|
||||
sortColumn += " DESC"
|
||||
}
|
||||
db = db.Order(sortColumn)
|
||||
} else {
|
||||
db = db.Order("campaign_closed_at DESC")
|
||||
}
|
||||
|
||||
if options.Limit > 0 {
|
||||
db = db.Offset(options.Offset).Limit(options.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
res := db.Find(&stats)
|
||||
return stats, res.Error
|
||||
}
|
||||
|
||||
// GetCampaignStatsCount returns the total count of campaign statistics
|
||||
func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UUID, search string) (int64, error) {
|
||||
func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
|
||||
db := r.DB.WithContext(ctx).Model(&database.CampaignStats{})
|
||||
@@ -1710,10 +1690,6 @@ func (r *Campaign) GetCampaignStatsCount(ctx context.Context, companyID *uuid.UU
|
||||
db = db.Where("company_id = ?", companyID)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
db = db.Where("campaign_name ILIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
res := db.Count(&count)
|
||||
return count, res.Error
|
||||
}
|
||||
@@ -1723,3 +1699,53 @@ func (r *Campaign) DeleteCampaignStats(ctx context.Context, campaignID *uuid.UUI
|
||||
res := r.DB.WithContext(ctx).Where("campaign_id = ?", campaignID).Delete(&database.CampaignStats{})
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// GetManualCampaignStats retrieves manual campaign statistics (those with null campaignID)
|
||||
func (r *Campaign) GetManualCampaignStats(ctx context.Context, companyID *uuid.UUID) ([]database.CampaignStats, error) {
|
||||
var stats []database.CampaignStats
|
||||
|
||||
db := r.DB.WithContext(ctx).Where("campaign_id IS NULL").Order("created_at DESC")
|
||||
|
||||
if companyID != nil {
|
||||
db = db.Where("company_id = ?", companyID)
|
||||
}
|
||||
|
||||
res := db.Find(&stats)
|
||||
return stats, res.Error
|
||||
}
|
||||
|
||||
// GetManualCampaignStatsCount returns the total count of manual campaign statistics
|
||||
func (r *Campaign) GetManualCampaignStatsCount(ctx context.Context, companyID *uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
|
||||
db := r.DB.WithContext(ctx).Model(&database.CampaignStats{}).Where("campaign_id IS NULL")
|
||||
|
||||
if companyID != nil {
|
||||
db = db.Where("company_id = ?", companyID)
|
||||
}
|
||||
|
||||
res := db.Count(&count)
|
||||
return count, res.Error
|
||||
}
|
||||
|
||||
// GetCampaignStatsByID retrieves campaign statistics by stats ID
|
||||
func (r *Campaign) GetCampaignStatsByID(ctx context.Context, statsID *uuid.UUID) (*database.CampaignStats, error) {
|
||||
var stats database.CampaignStats
|
||||
res := r.DB.WithContext(ctx).Where("id = ?", statsID).First(&stats)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// UpdateCampaignStats updates campaign statistics by ID
|
||||
func (r *Campaign) UpdateCampaignStats(ctx context.Context, statsID *uuid.UUID, updateData map[string]interface{}) error {
|
||||
res := r.DB.WithContext(ctx).Model(&database.CampaignStats{}).Where("id = ?", statsID).Updates(updateData)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// DeleteCampaignStatsByID deletes campaign statistics by stats ID
|
||||
func (r *Campaign) DeleteCampaignStatsByID(ctx context.Context, statsID *uuid.UUID) error {
|
||||
res := r.DB.WithContext(ctx).Where("id = ?", statsID).Delete(&database.CampaignStats{})
|
||||
return res.Error
|
||||
}
|
||||
|
||||
+196
-4
@@ -3838,8 +3838,8 @@ func (c *Campaign) GetCampaignStats(ctx context.Context, session *model.Session,
|
||||
return c.CampaignRepository.GetCampaignStats(ctx, campaignID)
|
||||
}
|
||||
|
||||
// GetAllCampaignStats retrieves all campaign statistics with pagination
|
||||
func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Session, options *vo.QueryArgs, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
|
||||
// GetAllCampaignStats retrieves all campaign statistics
|
||||
func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
|
||||
result := model.NewEmptyResult[database.CampaignStats]()
|
||||
|
||||
// Check permissions
|
||||
@@ -3853,7 +3853,7 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
|
||||
}
|
||||
|
||||
// Get the data
|
||||
stats, err := c.CampaignRepository.GetAllCampaignStats(ctx, companyID, options)
|
||||
stats, err := c.CampaignRepository.GetAllCampaignStats(ctx, companyID)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
@@ -3865,11 +3865,203 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
|
||||
}
|
||||
|
||||
result.Rows = rows
|
||||
result.HasNextPage = false // Simple implementation without pagination for now
|
||||
result.HasNextPage = false
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateManualCampaignStats creates campaign statistics manually without requiring a campaign
|
||||
func (c *Campaign) CreateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
|
||||
// Check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// Set up the stats record
|
||||
id := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
// Use provided date for created_at and updated_at, or current time if not provided
|
||||
var statsDate time.Time
|
||||
if req.CampaignStartDate != nil {
|
||||
statsDate = *req.CampaignStartDate
|
||||
} else {
|
||||
statsDate = now
|
||||
}
|
||||
|
||||
// Set required fields
|
||||
req.ID = &id
|
||||
req.CreatedAt = &statsDate
|
||||
req.UpdatedAt = &statsDate
|
||||
req.CampaignID = nil // No campaign reference for manual stats
|
||||
|
||||
// Calculate rates
|
||||
if req.TotalRecipients > 0 {
|
||||
req.OpenRate = float64(req.TrackingPixelLoaded) / float64(req.TotalRecipients) * 100
|
||||
req.ClickRate = float64(req.WebsiteVisits) / float64(req.TotalRecipients) * 100
|
||||
req.SubmissionRate = float64(req.DataSubmissions) / float64(req.TotalRecipients) * 100
|
||||
req.ReportRate = float64(req.Reported) / float64(req.TotalRecipients) * 100
|
||||
}
|
||||
|
||||
// Calculate total events
|
||||
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
|
||||
|
||||
// Insert the stats
|
||||
c.Logger.Debugf("inserting manual campaign stats", "statsID", req.ID.String())
|
||||
err = c.CampaignRepository.InsertCampaignStats(ctx, req)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to insert manual campaign stats", "error", err, "statsID", req.ID.String())
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
c.Logger.Debugf("successfully inserted manual campaign stats", "statsID", req.ID.String())
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetManualCampaignStats retrieves manual campaign statistics (those without campaignID)
|
||||
func (c *Campaign) GetManualCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
|
||||
result := model.NewEmptyResult[database.CampaignStats]()
|
||||
|
||||
// Check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// Get manual stats (those with null campaignID)
|
||||
stats, err := c.CampaignRepository.GetManualCampaignStats(ctx, companyID)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// Convert to result format with pointers
|
||||
rows := make([]*database.CampaignStats, len(stats))
|
||||
for i := range stats {
|
||||
rows[i] = &stats[i]
|
||||
}
|
||||
|
||||
result.Rows = rows
|
||||
result.HasNextPage = false
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateManualCampaignStats updates manual campaign statistics
|
||||
func (c *Campaign) UpdateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
|
||||
// Check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// Get existing stats to ensure it's manual (no campaignID)
|
||||
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// Ensure this is a manual stat (no campaignID)
|
||||
if existingStats.CampaignID != nil {
|
||||
return nil, errs.Wrap(errors.New("cannot update system-generated campaign stats"))
|
||||
}
|
||||
|
||||
// Set updated timestamp
|
||||
now := time.Now()
|
||||
req.UpdatedAt = &now
|
||||
req.CampaignID = nil // Ensure it remains manual
|
||||
|
||||
// Calculate rates
|
||||
if req.TotalRecipients > 0 {
|
||||
req.OpenRate = float64(req.TrackingPixelLoaded) / float64(req.TotalRecipients) * 100
|
||||
req.ClickRate = float64(req.WebsiteVisits) / float64(req.TotalRecipients) * 100
|
||||
req.SubmissionRate = float64(req.DataSubmissions) / float64(req.TotalRecipients) * 100
|
||||
req.ReportRate = float64(req.Reported) / float64(req.TotalRecipients) * 100
|
||||
}
|
||||
|
||||
// Calculate total events
|
||||
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
|
||||
|
||||
// Prepare update data
|
||||
updateData := map[string]interface{}{
|
||||
"updated_at": req.UpdatedAt,
|
||||
"campaign_name": req.CampaignName,
|
||||
"company_id": req.CompanyID,
|
||||
"campaign_start_date": req.CampaignStartDate,
|
||||
"campaign_end_date": req.CampaignEndDate,
|
||||
"campaign_closed_at": req.CampaignClosedAt,
|
||||
"total_recipients": req.TotalRecipients,
|
||||
"total_events": req.TotalEvents,
|
||||
"emails_sent": req.EmailsSent,
|
||||
"tracking_pixel_loaded": req.TrackingPixelLoaded,
|
||||
"website_visits": req.WebsiteVisits,
|
||||
"data_submissions": req.DataSubmissions,
|
||||
"reported": req.Reported,
|
||||
"open_rate": req.OpenRate,
|
||||
"click_rate": req.ClickRate,
|
||||
"submission_rate": req.SubmissionRate,
|
||||
"report_rate": req.ReportRate,
|
||||
"template_name": req.TemplateName,
|
||||
"campaign_type": req.CampaignType,
|
||||
}
|
||||
|
||||
// Update the stats
|
||||
err = c.CampaignRepository.UpdateCampaignStats(ctx, req.ID, updateData)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to update manual campaign stats", "error", err, "statsID", req.ID.String())
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
c.Logger.Debugf("successfully updated manual campaign stats", "statsID", req.ID.String())
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// DeleteManualCampaignStats deletes manual campaign statistics by ID
|
||||
func (c *Campaign) DeleteManualCampaignStats(ctx context.Context, session *model.Session, statsID *uuid.UUID) error {
|
||||
// Check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// Get existing stats to ensure it's manual (no campaignID)
|
||||
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, statsID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// Ensure this is a manual stat (no campaignID)
|
||||
if existingStats.CampaignID != nil {
|
||||
return errs.Wrap(errors.New("cannot delete system-generated campaign stats"))
|
||||
}
|
||||
|
||||
// Delete the stats
|
||||
err = c.CampaignRepository.DeleteCampaignStatsByID(ctx, statsID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to delete manual campaign stats", "error", err, "statsID", statsID.String())
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
c.Logger.Debugf("successfully deleted manual campaign stats", "statsID", statsID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessReportedCSV processes a CSV file with reported recipients
|
||||
func (c *Campaign) ProcessReportedCSV(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -862,18 +862,82 @@ export class API {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all campaign statistics with pagination.
|
||||
* Get all campaign statistics.
|
||||
*
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAllCampaignStats: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(
|
||||
`/campaign/stats/all?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
|
||||
)
|
||||
);
|
||||
getAllCampaignStats: async (companyID = null) => {
|
||||
const companyQuery = companyID ? `?${this.companyQuery(companyID)}` : '';
|
||||
return await getJSON(this.getPath(`/campaign/stats/all${companyQuery}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all manual campaign statistics (those without campaignID).
|
||||
*
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getManualCampaignStats: async (companyID = null) => {
|
||||
const companyQuery = companyID ? `?${this.companyQuery(companyID)}` : '';
|
||||
return await getJSON(this.getPath(`/campaign/stats/manual${companyQuery}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create manual campaign statistics.
|
||||
*
|
||||
* @param {object} stats
|
||||
* @param {string} stats.campaignName
|
||||
* @param {string} [stats.companyId]
|
||||
* @param {number} stats.totalRecipients
|
||||
* @param {number} stats.emailsSent
|
||||
* @param {number} stats.trackingPixelLoaded
|
||||
* @param {number} stats.websiteVisits
|
||||
* @param {number} stats.dataSubmissions
|
||||
* @param {number} stats.reported
|
||||
* @param {string} [stats.templateName]
|
||||
* @param {string} [stats.campaignType]
|
||||
* @param {string} [stats.campaignStartDate] - ISO date string
|
||||
* @param {string} [stats.campaignEndDate] - ISO date string
|
||||
* @param {string} [stats.campaignClosedAt] - ISO date string
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
createStats: async (stats) => {
|
||||
return await postJSON(this.getPath('/campaign/stats'), stats);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update manual campaign statistics by ID.
|
||||
*
|
||||
* @param {string} statsID
|
||||
* @param {object} stats
|
||||
* @param {string} stats.campaignName
|
||||
* @param {string} [stats.companyId]
|
||||
* @param {number} stats.totalRecipients
|
||||
* @param {number} stats.emailsSent
|
||||
* @param {number} stats.trackingPixelLoaded
|
||||
* @param {number} stats.websiteVisits
|
||||
* @param {number} stats.dataSubmissions
|
||||
* @param {number} stats.reported
|
||||
* @param {string} [stats.templateName]
|
||||
* @param {string} [stats.campaignType]
|
||||
* @param {string} [stats.campaignStartDate] - ISO date string
|
||||
* @param {string} [stats.campaignEndDate] - ISO date string
|
||||
* @param {string} [stats.campaignClosedAt] - ISO date string
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
updateStats: async (statsID, stats) => {
|
||||
return await putJSON(this.getPath(`/campaign/stats/${statsID}`), stats);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete manual campaign statistics by ID.
|
||||
*
|
||||
* @param {string} statsID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
deleteStats: async (statsID) => {
|
||||
return await deleteJSON(this.getPath(`/campaign/stats/${statsID}`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
@@ -324,6 +325,10 @@
|
||||
on:click={() => openViewCommentModal(company)}
|
||||
/>
|
||||
<TableDropDownButton name="Export" on:click={() => openExportCompanyModal(company)} />
|
||||
<TableDropDownButton
|
||||
name="Custom Stats"
|
||||
on:click={() => goto(`/company/${company.id}/stats`)}
|
||||
/>
|
||||
<TableDeleteButton on:click={() => openDeleteAlert(company)} />
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
|
||||
// Get company ID from URL params
|
||||
$: companyId = $page.params.id;
|
||||
|
||||
// bindings
|
||||
let form = null;
|
||||
const formValues = {
|
||||
id: null,
|
||||
campaignName: '',
|
||||
totalRecipients: '',
|
||||
emailsSent: '',
|
||||
trackingPixelLoaded: '',
|
||||
websiteVisits: '',
|
||||
dataSubmissions: '',
|
||||
reported: '',
|
||||
date: ''
|
||||
};
|
||||
|
||||
// data
|
||||
let modalError = '';
|
||||
let customStats = [];
|
||||
let company = {
|
||||
name: ''
|
||||
};
|
||||
|
||||
let isModalVisible = false;
|
||||
let isSubmitting = false;
|
||||
let isTableLoading = true;
|
||||
let modalMode = null;
|
||||
let modalText = '';
|
||||
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
$: {
|
||||
modalText = modalMode === 'create' ? 'New Custom Stats' : 'Update Custom Stats';
|
||||
}
|
||||
|
||||
// hooks
|
||||
onMount(() => {
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// component logic
|
||||
const refreshData = async () => {
|
||||
await Promise.all([getCompany(), refreshCustomStats()]);
|
||||
};
|
||||
|
||||
const getCompany = async () => {
|
||||
try {
|
||||
const res = await api.company.getByID(companyId);
|
||||
if (res.success) {
|
||||
company = res.data;
|
||||
} else {
|
||||
throw res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to get company', 'Error');
|
||||
console.error('failed to get company', e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCustomStats = async () => {
|
||||
try {
|
||||
isTableLoading = true;
|
||||
customStats = await getCustomStats();
|
||||
} catch (e) {
|
||||
addToast('Failed to get custom stats', 'Error');
|
||||
console.error('failed to get custom stats', e);
|
||||
} finally {
|
||||
isTableLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomStats = async () => {
|
||||
try {
|
||||
const res = await api.campaign.getManualCampaignStats(companyId);
|
||||
if (res.success) {
|
||||
return res.data.rows;
|
||||
}
|
||||
throw new res.error();
|
||||
} catch (e) {
|
||||
addToast('Failed to get custom stats', 'Error');
|
||||
console.error('failed to get custom stats', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getStatsById = async (id) => {
|
||||
try {
|
||||
// We'll need to implement this endpoint or get it from the list
|
||||
const stat = customStats.find((s) => s.id === id);
|
||||
return stat;
|
||||
} catch (e) {
|
||||
addToast('Failed to get stats', 'Error');
|
||||
console.error('failed to get stats', e);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
isSubmitting = true;
|
||||
if (modalMode === 'create') {
|
||||
await create();
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
modalError = '';
|
||||
try {
|
||||
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
|
||||
|
||||
const payload = {
|
||||
campaignName: formValues.campaignName,
|
||||
totalRecipients: parseInt(formValues.totalRecipients) || 0,
|
||||
emailsSent: parseInt(formValues.emailsSent) || 0,
|
||||
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
|
||||
websiteVisits: parseInt(formValues.websiteVisits) || 0,
|
||||
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
|
||||
reported: parseInt(formValues.reported) || 0,
|
||||
campaignType: 'Scheduled',
|
||||
templateName: '',
|
||||
companyId: companyId,
|
||||
campaignStartDate: campaignDate,
|
||||
campaignEndDate: campaignDate,
|
||||
campaignClosedAt: campaignDate
|
||||
};
|
||||
|
||||
const res = await api.campaign.createStats(payload);
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Custom stats created', 'Success');
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
addToast('Failed to create custom stats', 'Error');
|
||||
console.error('failed to create custom stats:', e);
|
||||
}
|
||||
refreshCustomStats();
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
modalError = '';
|
||||
try {
|
||||
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
|
||||
|
||||
const payload = {
|
||||
campaignName: formValues.campaignName,
|
||||
totalRecipients: parseInt(formValues.totalRecipients) || 0,
|
||||
emailsSent: parseInt(formValues.emailsSent) || 0,
|
||||
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
|
||||
websiteVisits: parseInt(formValues.websiteVisits) || 0,
|
||||
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
|
||||
reported: parseInt(formValues.reported) || 0,
|
||||
campaignType: 'Scheduled',
|
||||
templateName: '',
|
||||
companyId: companyId,
|
||||
campaignStartDate: campaignDate,
|
||||
campaignEndDate: campaignDate,
|
||||
campaignClosedAt: campaignDate
|
||||
};
|
||||
|
||||
const res = await api.campaign.updateStats(formValues.id, payload);
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Custom stats updated', 'Success');
|
||||
closeUpdateModal();
|
||||
} catch (e) {
|
||||
addToast('Failed to update custom stats', 'Error');
|
||||
console.error('failed to update custom stats', e);
|
||||
}
|
||||
refreshCustomStats();
|
||||
};
|
||||
|
||||
const openDeleteAlert = async (stats) => {
|
||||
isDeleteAlertVisible = true;
|
||||
deleteValues.id = stats.id;
|
||||
deleteValues.name = stats.campaignName;
|
||||
};
|
||||
|
||||
const onClickDelete = async (id) => {
|
||||
const action = api.campaign.deleteStats(id);
|
||||
action
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
refreshCustomStats();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('failed to delete custom stats:', e);
|
||||
});
|
||||
return action;
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalMode = 'create';
|
||||
modalError = '';
|
||||
resetFormValues();
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modalError = '';
|
||||
isModalVisible = false;
|
||||
resetFormValues();
|
||||
if (form) form.reset();
|
||||
};
|
||||
|
||||
const openUpdateModal = async (id) => {
|
||||
modalMode = 'update';
|
||||
try {
|
||||
showIsLoading();
|
||||
const stats = await getStatsById(id);
|
||||
if (stats) {
|
||||
formValues.id = stats.id;
|
||||
formValues.campaignName = stats.campaignName;
|
||||
formValues.totalRecipients = stats.totalRecipients.toString();
|
||||
formValues.emailsSent = stats.emailsSent.toString();
|
||||
formValues.trackingPixelLoaded = stats.trackingPixelLoaded.toString();
|
||||
formValues.websiteVisits = stats.websiteVisits.toString();
|
||||
formValues.dataSubmissions = stats.dataSubmissions.toString();
|
||||
formValues.reported = stats.reported.toString();
|
||||
formValues.date = stats.campaignStartDate
|
||||
? new Date(stats.campaignStartDate).toISOString().slice(0, 16)
|
||||
: '';
|
||||
isModalVisible = true;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to get stats', 'Error');
|
||||
console.error('failed to get stats', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const closeUpdateModal = () => {
|
||||
isModalVisible = false;
|
||||
modalError = '';
|
||||
resetFormValues();
|
||||
if (form) form.reset();
|
||||
};
|
||||
|
||||
const resetFormValues = () => {
|
||||
formValues.id = null;
|
||||
formValues.campaignName = '';
|
||||
formValues.totalRecipients = '';
|
||||
formValues.emailsSent = '';
|
||||
formValues.trackingPixelLoaded = '';
|
||||
formValues.websiteVisits = '';
|
||||
formValues.dataSubmissions = '';
|
||||
formValues.reported = '';
|
||||
formValues.date = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Custom Campaign Stats" />
|
||||
<main>
|
||||
<Headline>
|
||||
Custom Stats - {company.name}
|
||||
</Headline>
|
||||
|
||||
<BigButton on:click={openCreateModal}>Add</BigButton>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'Campaign Name', size: 'large' },
|
||||
{ column: 'Recipients', size: 'small' },
|
||||
{ column: 'Open Rate', size: 'small' },
|
||||
{ column: 'Click Rate', size: 'small' },
|
||||
{ column: 'Submission Rate', size: 'small' },
|
||||
{ column: 'Report Rate', size: 'small' },
|
||||
{ column: 'Date', size: 'small' }
|
||||
]}
|
||||
sortable={[]}
|
||||
hasData={!!customStats.length}
|
||||
plural="custom statistics"
|
||||
isGhost={isTableLoading}
|
||||
>
|
||||
{#each customStats as stats}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => openUpdateModal(stats.id)}
|
||||
class="block w-full py-1 text-left font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{stats.campaignName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell value={stats.totalRecipients} />
|
||||
<TableCell value="{Math.round(stats.openRate)}%" />
|
||||
<TableCell value="{Math.round(stats.clickRate)}%" />
|
||||
<TableCell value="{Math.round(stats.submissionRate)}%" />
|
||||
<TableCell value="{Math.round(stats.reportRate)}%" />
|
||||
<TableCell value={stats.createdAt} isDate isRelative />
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableUpdateButton on:click={() => openUpdateModal(stats.id)} />
|
||||
<TableDeleteButton on:click={() => openDeleteAlert(stats)} />
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
|
||||
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={64}
|
||||
required
|
||||
bind:value={formValues.campaignName}
|
||||
placeholder="Campaign Name">Campaign Name</TextField
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
min="0"
|
||||
required
|
||||
bind:value={formValues.totalRecipients}
|
||||
placeholder="0">Total Recipients</TextField
|
||||
>
|
||||
|
||||
<TextField type="number" min="0" bind:value={formValues.emailsSent} placeholder="0"
|
||||
>Emails Sent</TextField
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={formValues.trackingPixelLoaded}
|
||||
placeholder="0">Email Opens (Read)</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
<FormColumn>
|
||||
<TextField type="number" min="0" bind:value={formValues.websiteVisits} placeholder="0"
|
||||
>Links Clicked</TextField
|
||||
>
|
||||
<TextField type="number" min="0" bind:value={formValues.dataSubmissions} placeholder="0"
|
||||
>Data Submissions</TextField
|
||||
>
|
||||
|
||||
<TextField type="number" min="0" bind:value={formValues.reported} placeholder="0"
|
||||
>Reported as Phishing</TextField
|
||||
>
|
||||
<TextField type="datetime-local" required bind:value={formValues.date}>Date</TextField>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
|
||||
<FormError message={modalError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
list={['All statistics data will be permanently removed']}
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
/>
|
||||
</main>
|
||||
@@ -261,15 +261,7 @@
|
||||
sortOrder: 'desc',
|
||||
perPage: 10
|
||||
});
|
||||
const options = {
|
||||
page: statsParams.currentPage,
|
||||
perPage: statsParams.perPage,
|
||||
sortBy: statsParams.sortBy,
|
||||
sortOrder: statsParams.sortOrder,
|
||||
search: statsParams.search,
|
||||
includeTest: includeTestCampaigns
|
||||
};
|
||||
const res = await api.campaign.getAllCampaignStats(options, contextCompanyID);
|
||||
const res = await api.campaign.getAllCampaignStats(contextCompanyID);
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
@@ -372,15 +364,7 @@
|
||||
completedCampaigns = completedRes.data.rows;
|
||||
}
|
||||
|
||||
const statsOptions = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
sortBy: 'campaign_closed_at',
|
||||
sortOrder: 'desc',
|
||||
search: '',
|
||||
includeTest: includeTestCampaigns
|
||||
};
|
||||
const statsRes = await api.campaign.getAllCampaignStats(statsOptions, contextCompanyID);
|
||||
const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID);
|
||||
if (statsRes.success) {
|
||||
campaignStats = [];
|
||||
await tick();
|
||||
@@ -650,9 +634,15 @@
|
||||
{#each campaignStats as stat}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<a href={`/campaign/${stat.campaignId}`}>
|
||||
{stat.campaignName}
|
||||
</a>
|
||||
{#if stat.campaignId}
|
||||
<a href={`/campaign/${stat.campaignId}`}>
|
||||
{stat.campaignName}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{stat.campaignName}
|
||||
</span>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell value={stat.templateName} />
|
||||
<TableCell value={stat.totalRecipients} />
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
import TableCellLink from '$lib/components/table/TableCellLink.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
@@ -19,8 +18,6 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
|
||||
Reference in New Issue
Block a user