Files
phishingclub/backend/controller/campaign.go
Ronni Skansing 42d3aa2c03 added hide test campaigns from campaigns page
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-02-04 22:25:19 +01:00

1180 lines
29 KiB
Go

package controller
import (
"bytes"
"encoding/csv"
"io"
"strconv"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/embedded"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// allowedCampaignColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TABLE, "updated_at"),
"closed_at": repository.TableColumn(database.CAMPAIGN_TABLE, "closed_at"),
"close_at": repository.TableColumn(database.CAMPAIGN_TABLE, "close_at"),
"anonymized_at": repository.TableColumn(database.CAMPAIGN_TABLE, "anonymized_at"),
"is_test": repository.TableColumn(database.CAMPAIGN_TABLE, "is_test"),
"send_start_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_start_at"),
"send_end_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_end_at"),
"template": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"name": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
}
// campaignEventColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var campaignEventColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "updated_at"),
"details": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "data"),
"ip": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "ip_address"),
"user-agent": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "user_agent"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"event": repository.TableColumn(database.EVENT_TABLE, "name"),
}
// allowedCampaignRecipientColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignRecipientColumns = map[string]string{
"created_at": "campaign_recipients.created_at",
"updated_at": "campaign_recipients.updated_at",
"send_at": "campaign_recipients.send_at",
"sent_at": "campaign_recipients.sent_at",
"cancelled_at": "campaign_recipients.cancelled_at",
"status": "campaign_recipients.notable_event_id",
"first_name": "recipients.first_name",
"last_name": "recipients.last_name",
"email": "recipients.email",
}
// Campaign is a Campaign controller
type Campaign struct {
Common
CampaignService *service.Campaign
}
// CloseCampaignByID closes campaign
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// close campaigns
err := c.CampaignService.CloseCampaignByID(
g.Request.Context(),
session,
id,
)
// handle responses
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Response.ValidationFailed(g, "", err)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a new campaign
func (c *Campaign) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create and schedule the campaign
id, err := c.CampaignService.Create(g.Request.Context(), session, &req)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": id.String(),
})
}
// GetAllEventTypes gets all event types
func (c *Campaign) GetAllEventTypes(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
c.Response.Unauthorized(g)
return
}
// get all event names
// we pick them out from the in memory cache
ev := []gin.H{}
for name, id := range cache.EventIDByName {
ev = append(ev, gin.H{
"id": id,
"name": name,
})
}
c.Response.OK(g, ev)
}
// GetByID gets a campaign by its id
func (c *Campaign) GetByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByID(
g.Request.Context(),
session,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
WithEvasionPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetByName gets a campaign by name
func (c *Campaign) GetByName(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
name := g.Param("name")
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByName(
g,
session,
name,
companyID,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
WithEvasionPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetResultStats get campaign result stats
func (c *Campaign) GetResultStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
stats, err := c.CampaignService.GetResultStats(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetCampaignStats get campaign stats
// if no company id is provided it gets the global stats including all companies
func (c *Campaign) GetStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
// get
stats, err := c.CampaignService.GetStats(
g.Request.Context(),
session,
companyID,
includeTestCampaigns,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAll gets all campaigns with pagination
func (c *Campaign) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// default to including test campaigns for backward compatibility
// only exclude when explicitly set to "false"
includeTestCampaigns := g.Query("includeTest") != "false"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get all campaigns
campaigns, err := c.CampaignService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAll gets all campaigns within dates
func (c *Campaign) GetAllWithinDates(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get start and end date for query
startDate, err := time.Parse(time.RFC3339Nano, g.Query("start"))
if err != nil {
c.Response.ValidationFailed(g, "start", err)
return
}
endDate, err := time.Parse(time.RFC3339Nano, g.Query("end"))
if err != nil {
c.Response.ValidationFailed(g, "end", err)
return
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllWithinDates(
g.Request.Context(),
session,
startDate,
endDate,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllActive gets all active campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllActive(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllActive(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllUpcoming gets all upcoming campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllUpcoming(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllUpcoming(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllFinished gets all finished campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllFinished(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = true
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllFinished(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetEventsByCampaignID gets events by campaign id
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
// remap query args
queryArgs.RemapOrderBy(campaignEventColumns)
// set default sort order to desc
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
var since *time.Time
s, err := time.Parse(time.RFC3339Nano, g.Query("since"))
if err == nil {
since = &s
}
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
since,
nil,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, events)
}
// ExportEventsAsCSV exports a all campaign events as a CSV
func (c *Campaign) ExportEventsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_events.csv")
}
// ExportSubmissionsAsCSV exports all campaign submissions as a CSV
func (c *Campaign) ExportSubmissionsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// filter for submission events only
submissionEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
eventTypeFilter := []string{submissionEventID.String()}
// get submission events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
eventTypeFilter,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Submitted at",
"Recipient first name",
"Recipient last name",
"Recipient email",
"Submitted data",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_submissions.csv")
}
func (c *Campaign) GetCampaignEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := c.CampaignService.GetCampaignEmailBody(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, email)
}
// GetCampaignURL gets a recipient landing page URL
func (c *Campaign) GetCampaignURL(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
url, err := c.CampaignService.GetLandingPageURLByCampaignRecipientID(
g.Request.Context(),
session,
id,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, url)
}
// GetRecipientsByCampaignID gets recipients by campaign id
func (c *Campaign) GetRecipientsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// endpoints is handled a bit differently and allows to
// fetch an unlimited amount of rows if no offset and limit is set.
offset := g.DefaultQuery("offset", "")
limit := g.DefaultQuery("limit", "")
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
// special case to retrieve ALL rows
if offset == "" && limit == "" {
queryArgs.Offset = 0
queryArgs.Limit = 0
}
// remap query args
queryArgs.DefaultSortBy("created_at")
queryArgs.RemapOrderBy(allowedCampaignRecipientColumns)
// get recipients by campaign id
recipients, err := c.CampaignService.GetRecipientsByCampaignID(
g.Request.Context(),
session,
id,
&repository.CampaignRecipientOption{
QueryArgs: queryArgs,
WithRecipient: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, recipients)
}
// TrackingPixel returns a tracking pixel
func (c *Campaign) TrackingPixel(g *gin.Context) {
// get the campaign recipient id from the query
campaignRecipientID := g.Query("upn") // expect the campaign recipient id to be in here
if campaignRecipientID == "" {
c.Response.NotFound(g)
return
}
campaignRecipientUUID, err := uuid.Parse(campaignRecipientID)
if err != nil {
c.Logger.Debugw(errs.MsgFailedToParseRequest,
"error", err,
)
c.Response.NotFound(g)
return
}
err = c.CampaignService.SaveTrackingPixelLoaded(
g,
&campaignRecipientUUID,
)
if err != nil {
c.Logger.Debugw("failed to save tracking pixel loaded event",
"error", err,
)
c.Response.NotFound(g)
return
}
g.Header("Content-Type", "image/gif")
if !build.Flags.Production {
g.File("./embedded/tracking-pixel/sendgrid/open.gif")
return
}
_, err = g.Writer.Write(embedded.TrackingPixel)
if err != nil {
c.Logger.Errorw("failed to write tracking pixel", "error", err)
}
}
// UpdateByID updates a campaign by its id
func (c *Campaign) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update the campaign
err := c.CampaignService.UpdateByID(g.Request.Context(), session, id, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
// handle responses
c.Response.OK(g, gin.H{})
}
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// set sent at time
err := c.CampaignService.SetSentAtByCampaignRecipientID(g.Request.Context(), session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient
func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// send message (email or API depending on campaign template configuration)
err := c.CampaignService.SendEmailByCampaignRecipientID(g.Request.Context(), session, id)
// handle responses - sending failures are expected (invalid recipient email etc)
// so return as bad request instead of internal server error
if err != nil {
c.Response.BadRequestMessage(g, err.Error())
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign by its id
func (c *Campaign) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignService.DeleteByID(g, session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteEventByID deletes a single campaign event by its id
func (c *Campaign) DeleteEventByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete event
err := c.CampaignService.DeleteEventByID(g.Request.Context(), session, id)
// handle responses
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Response.ValidationFailed(g, "", err)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AnonymizeByID anonymizes a campaign by its id
func (c *Campaign) AnonymizeByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// anonymize
err := c.CampaignService.AnonymizeByID(g, session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetCampaignStats gets campaign statistics by campaign ID
func (c *Campaign) GetCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get stats
stats, err := c.CampaignService.GetCampaignStats(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAllCampaignStats gets all campaign statistics with pagination
func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get stats
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
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])
// get required column indices from query params
emailColStr := g.Query("emailColumn")
if emailColStr == "" {
c.Response.ValidationFailed(g, "emailColumn", errors.New("emailColumn query parameter is required"))
return
}
dateColStr := g.Query("dateColumn")
if dateColStr == "" {
c.Response.ValidationFailed(g, "dateColumn", errors.New("dateColumn query parameter is required"))
return
}
emailColIdx, err := strconv.Atoi(emailColStr)
if err != nil || emailColIdx < 0 {
c.Response.ValidationFailed(g, "emailColumn", errors.New("emailColumn must be a valid non-negative integer"))
return
}
dateColIdx, err := strconv.Atoi(dateColStr)
if err != nil || dateColIdx < 0 {
c.Response.ValidationFailed(g, "dateColumn", errors.New("dateColumn must be a valid non-negative integer"))
return
}
c.Logger.Debugw("using provided column indices", "emailColumn", emailColIdx, "dateColumn", dateColIdx)
// process CSV
processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records, emailColIdx, dateColIdx)
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",
})
}