mirror of
https://github.com/knadh/listmonk.git
synced 2025-12-05 16:00:03 +01:00
This patch introduces new `campaigns:get_all` and `campaigns:manage_all` permissions which alter the behaviour of the the old `campaigns:get` and `campaigns:manage` permissions. This is a subtle breaking behavioural change. Old: - `campaigns:get` -> View all campaigns irrespective of a user's list permissions. - `campaigns:manage` -> Manage all campaigns irrespective of a user's list permissions. New: - `campaigns:get_all` -> View all campaigns irrespective of a user's list permissions. - `campaigns:manage_all` -> Manage all campaigns irrespective of a user's list permissions. - `campaigns:get` -> View only the campaigns that have at least one list to which which a user has get or manage access. - `campaigns:manage` -> Manage only the campaigns that have at list one list to which a user has get or manage access. In addition, this patch refactors and cleans up certain permission related logic and functions.
448 lines
15 KiB
Go
448 lines
15 KiB
Go
package core
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid/v5"
|
|
"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"
|
|
|
|
campaignTplDefault = "default"
|
|
campaignTplArchive = "archive"
|
|
)
|
|
|
|
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
|
|
// query expression. It also returns the total number of records in the DB.
|
|
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
|
|
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
|
|
|
|
if statuses == nil {
|
|
statuses = []string{}
|
|
}
|
|
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
|
|
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
|
var out models.Campaigns
|
|
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, getAll, pq.Array(permittedLists), offset, limit); err != nil {
|
|
c.log.Printf("error fetching campaigns: %v", err)
|
|
return nil, 0, 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, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
total := 0
|
|
if len(out) > 0 {
|
|
total = out[0].Total
|
|
}
|
|
|
|
return out, total, nil
|
|
}
|
|
|
|
// GetCampaign retrieves a campaign.
|
|
func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
|
return c.getCampaign(id, uuid, archiveSlug, campaignTplDefault)
|
|
}
|
|
|
|
// GetArchivedCampaign retrieves a campaign with the archive template body.
|
|
func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
|
out, err := c.getCampaign(id, uuid, archiveSlug, campaignTplArchive)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
|
|
if !out.Archive {
|
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// getCampaign retrieves a campaign. If typlType=default, then the campaign's
|
|
// template body is returned as "template_body". If tplType="archive",
|
|
// the archive template is returned.
|
|
func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType 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, archiveSlug, tplType); 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
|
|
}
|
|
|
|
// GetArchivedCampaigns retrieves campaigns with a template body.
|
|
func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) {
|
|
var out models.Campaigns
|
|
if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit, campaignTplArchive); err != nil {
|
|
c.log.Printf("error fetching public campaigns: %v", err)
|
|
return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
total := 0
|
|
if len(out) > 0 {
|
|
total = out[0].Total
|
|
}
|
|
|
|
return out, total, nil
|
|
}
|
|
|
|
// CreateCampaign creates a new campaign.
|
|
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []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),
|
|
o.Archive,
|
|
o.ArchiveSlug,
|
|
o.ArchiveTemplateID,
|
|
o.ArchiveMeta,
|
|
pq.Array(mediaIDs),
|
|
); 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, mediaIDs []int) (models.Campaign, error) {
|
|
_, err := c.q.UpdateCampaign.Exec(id,
|
|
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),
|
|
o.Archive,
|
|
o.ArchiveSlug,
|
|
o.ArchiveTemplateID,
|
|
o.ArchiveMeta,
|
|
pq.Array(mediaIDs))
|
|
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 && cm.Status != models.CampaignStatusPaused {
|
|
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
|
|
}
|
|
|
|
// UpdateCampaignArchive updates a campaign's archive properties.
|
|
func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON, archiveSlug string) error {
|
|
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, archiveSlug, tplID, meta); err != nil {
|
|
c.log.Printf("error updating campaign: %v", err)
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return 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
|
|
}
|
|
|
|
// CampaignHasLists checks if a campaign has any of the given list IDs.
|
|
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
|
|
has := false
|
|
if err := c.q.CampaignHasLists.Get(&has, id, pq.Array(listIDs)); err != nil {
|
|
c.log.Printf("error checking campaign lists: %v", err)
|
|
return false, echo.NewHTTPError(http.StatusInternalServerError,
|
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return has, 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 {
|
|
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "campaign_id" {
|
|
return 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) (string, error) {
|
|
var url string
|
|
if err := c.q.RegisterLinkClick.Get(&url, 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 url, nil
|
|
}
|
|
|
|
// DeleteCampaignViews deletes campaign views older than a given date.
|
|
func (c *Core) DeleteCampaignViews(before time.Time) error {
|
|
if _, err := c.q.DeleteCampaignViews.Exec(before); err != nil {
|
|
c.log.Printf("error deleting campaign views: %s", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteCampaignLinkClicks deletes campaign views older than a given date.
|
|
func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {
|
|
if _, err := c.q.DeleteCampaignLinkClicks.Exec(before); err != nil {
|
|
c.log.Printf("error deleting campaign link clicks: %s", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
|
}
|
|
|
|
return nil
|
|
}
|