b5cd9498b1
This is a long pending refactor. All the DB, query, CRUD, and related logic scattered across HTTP handlers are now moved into a central `core` package with clean, abstracted methods, decoupling HTTP handlers from executing direct DB queries and other business logic. eg: `core.CreateList()`, `core.GetLists()` etc. - Remove obsolete subscriber methods. - Move optin hook queries to core. - Move campaign methods to `core`. - Move all campaign methods to `core`. - Move public page functions to `core`. - Move all template functions to `core`. - Move media and settings function to `core`. - Move handler middleware functions to `core`. - Move all bounce functions to `core`. - Move all dashboard functions to `core`. - Fix GetLists() not honouring type - Fix unwrapped JSON responses. - Clean up obsolete pre-core util function. - Replace SQL array null check with cardinality check. - Fix missing validations in `core` queries. - Remove superfluous deps on internal `subimporter`. - Add dashboard functions to `core`. - Fix broken domain ban check. - Fix broken subscriber check middleware. - Remove redundant error handling. - Remove obsolete functions. - Remove obsolete structs. - Remove obsolete queries and DB functions. - Document the `core` package.
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package core
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/knadh/listmonk/models"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
const (
|
|
CampaignAnalyticsViews = "views"
|
|
CampaignAnalyticsClicks = "clicks"
|
|
CampaignAnalyticsBounces = "bounces"
|
|
)
|
|
|
|
// QueryCampaigns retrieves campaigns optionally filtering them by
|
|
// the given arbitrary query expression.
|
|
func (c *Core) QueryCampaigns(searchStr string, statuses []string, orderBy, order string, offset, limit int) (models.Campaigns, error) {
|
|
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns)
|
|
|
|
if statuses == nil {
|
|
statuses = []string{}
|
|
}
|
|
|
|
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
|
var out models.Campaigns
|
|
if err := c.db.Select(&out, stmt, 0, pq.Array(statuses), queryStr, offset, limit); err != nil {
|
|
c.log.Printf("error fetching campaigns: %v", err)
|
|
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
for i := 0; i < len(out); i++ {
|
|
// Replace null tags.
|
|
if out[i].Tags == nil {
|
|
out[i].Tags = []string{}
|
|
}
|
|
}
|
|
|
|
// Lazy load stats.
|
|
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
|
c.log.Printf("error fetching campaign stats: %v", err)
|
|
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// GetCampaign retrieves a campaign.
|
|
func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) {
|
|
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
|
var uu interface{}
|
|
if uuid != "" {
|
|
uu = uuid
|
|
}
|
|
|
|
var out models.Campaigns
|
|
if err := c.q.GetCampaign.Select(&out, id, uu); err != nil {
|
|
// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
|
|
c.log.Printf("error fetching campaign: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if len(out) == 0 {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
|
|
}
|
|
|
|
for i := 0; i < len(out); i++ {
|
|
// Replace null tags.
|
|
if out[i].Tags == nil {
|
|
out[i].Tags = []string{}
|
|
}
|
|
}
|
|
|
|
// Lazy load stats.
|
|
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
|
c.log.Printf("error fetching campaign stats: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return out[0], nil
|
|
}
|
|
|
|
// GetCampaignForPreview retrieves a campaign with a template body.
|
|
func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
|
|
var out models.Campaign
|
|
if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
c.log.Printf("error fetching campaign: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// CreateCampaign creates a new campaign.
|
|
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int) (models.Campaign, error) {
|
|
uu, err := uuid.NewV4()
|
|
if err != nil {
|
|
c.log.Printf("error generating UUID: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
|
}
|
|
|
|
// Insert and read ID.
|
|
var newID int
|
|
if err := c.q.CreateCampaign.Get(&newID,
|
|
uu,
|
|
o.Type,
|
|
o.Name,
|
|
o.Subject,
|
|
o.FromEmail,
|
|
o.Body,
|
|
o.AltBody,
|
|
o.ContentType,
|
|
o.SendAt,
|
|
o.Headers,
|
|
pq.StringArray(normalizeTags(o.Tags)),
|
|
o.Messenger,
|
|
o.TemplateID,
|
|
pq.Array(listIDs),
|
|
); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
|
|
}
|
|
|
|
c.log.Printf("error creating campaign: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
out, err := c.GetCampaign(newID, "")
|
|
if err != nil {
|
|
return models.Campaign{}, err
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// UpdateCampaign updates a campaign.
|
|
func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, sendLater bool) (models.Campaign, error) {
|
|
_, err := c.q.UpdateCampaign.Exec(id,
|
|
o.Name,
|
|
o.Subject,
|
|
o.FromEmail,
|
|
o.Body,
|
|
o.AltBody,
|
|
o.ContentType,
|
|
o.SendAt,
|
|
sendLater,
|
|
o.Headers,
|
|
pq.StringArray(normalizeTags(o.Tags)),
|
|
o.Messenger,
|
|
o.TemplateID,
|
|
pq.Array(listIDs))
|
|
if err != nil {
|
|
c.log.Printf("error updating campaign: %v", err)
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
out, err := c.GetCampaign(id, "")
|
|
if err != nil {
|
|
return models.Campaign{}, err
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// UpdateCampaignStatus updates a campaign's status, eg: draft to running.
|
|
func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) {
|
|
cm, err := c.GetCampaign(id, "")
|
|
if err != nil {
|
|
return models.Campaign{}, err
|
|
}
|
|
|
|
errMsg := ""
|
|
switch status {
|
|
case models.CampaignStatusDraft:
|
|
if cm.Status != models.CampaignStatusScheduled {
|
|
errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
|
|
}
|
|
case models.CampaignStatusScheduled:
|
|
if cm.Status != models.CampaignStatusDraft {
|
|
errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
|
|
}
|
|
if !cm.SendAt.Valid {
|
|
errMsg = c.i18n.T("campaigns.needsSendAt")
|
|
}
|
|
|
|
case models.CampaignStatusRunning:
|
|
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
|
errMsg = c.i18n.T("campaigns.onlyPausedDraft")
|
|
}
|
|
case models.CampaignStatusPaused:
|
|
if cm.Status != models.CampaignStatusRunning {
|
|
errMsg = c.i18n.T("campaigns.onlyActivePause")
|
|
}
|
|
case models.CampaignStatusCancelled:
|
|
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
|
errMsg = c.i18n.T("campaigns.onlyActiveCancel")
|
|
}
|
|
}
|
|
|
|
if len(errMsg) > 0 {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
|
}
|
|
|
|
res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status)
|
|
if err != nil {
|
|
c.log.Printf("error updating campaign status: %v", err)
|
|
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
cm.Status = status
|
|
return cm, nil
|
|
}
|
|
|
|
// DeleteCampaign deletes a campaign.
|
|
func (c *Core) DeleteCampaign(id int) error {
|
|
res, err := c.q.DeleteCampaign.Exec(id)
|
|
if err != nil {
|
|
c.log.Printf("error deleting campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRunningCampaignStats returns the progress stats of running campaigns.
|
|
func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
|
|
out := []models.CampaignStats{}
|
|
if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
|
|
c.log.Printf("error fetching campaign stats: %v", err)
|
|
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
} else if len(out) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) {
|
|
// Pick campaign view counts or click counts.
|
|
var stmt *sqlx.Stmt
|
|
switch typ {
|
|
case "views":
|
|
stmt = c.q.GetCampaignViewCounts
|
|
case "clicks":
|
|
stmt = c.q.GetCampaignClickCounts
|
|
case "bounces":
|
|
stmt = c.q.GetCampaignBounceCounts
|
|
default:
|
|
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
|
|
}
|
|
|
|
if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) {
|
|
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates"))
|
|
}
|
|
|
|
out := []models.CampaignAnalyticsCount{}
|
|
if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
|
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
|
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs.
|
|
func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) {
|
|
out := []models.CampaignAnalyticsLink{}
|
|
if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
|
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
|
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// RegisterCampaignView registers a subscriber's view on a campaign.
|
|
func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
|
|
if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
|
c.log.Printf("error registering campaign view: %s", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterCampaignLinkClick registers a subscriber's link click on a campaign.
|
|
func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) error {
|
|
if _, err := c.q.RegisterLinkClick.Exec(linkUUID, campUUID, subUUID); err != nil {
|
|
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink"))
|
|
}
|
|
|
|
c.log.Printf("error registering link click: %s", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
|
}
|
|
return nil
|
|
}
|