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", }) }