Skip to content

Commit

Permalink
Merge branch 'campaign-analytics'
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Sep 19, 2021
2 parents 623030a + 6a31697 commit 4b127f1
Show file tree
Hide file tree
Showing 58 changed files with 1,185 additions and 765 deletions.
104 changes: 90 additions & 14 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/gofrs/uuid"
"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
Expand Down Expand Up @@ -49,6 +50,17 @@ type campaignContentReq struct {
To string `json:"to"`
}

type campCountStats struct {
CampaignID int `db:"campaign_id" json:"campaign_id"`
Count int `db:"count" json:"count"`
Timestamp time.Time `db:"timestamp" json:"timestamp"`
}

type campTopLinks struct {
URL string `db:"url" json:"url"`
Count int `db:"count" json:"count"`
}

type campaignStats struct {
ID int `db:"id" json:"id"`
Status string `db:"status" json:"status"`
Expand Down Expand Up @@ -96,23 +108,11 @@ func handleGetCampaigns(c echo.Context) error {
if id > 0 {
single = true
}
if query != "" {
query = `%` +
string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%`
}

// Sort params.
if !strSliceContains(orderBy, campaignQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortDesc
}

stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order)
queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns)

// Unsafe to ignore scanning fields not present in models.Campaigns.
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
Expand Down Expand Up @@ -605,6 +605,64 @@ func handleTestCampaign(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}

// handleGetCampaignViewAnalytics retrieves view counts for a campaign.
func handleGetCampaignViewAnalytics(c echo.Context) error {
var (
app = c.Get("app").(*App)

typ = c.Param("type")
from = c.QueryParams().Get("from")
to = c.QueryParams().Get("to")
)

ids, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}

if len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
}

// Pick campaign view counts or click counts.
var stmt *sqlx.Stmt
switch typ {
case "views":
stmt = app.queries.GetCampaignViewCounts
case "clicks":
stmt = app.queries.GetCampaignClickCounts
case "bounces":
stmt = app.queries.GetCampaignBounceCounts
case "links":
out := make([]campTopLinks, 0)
if err := app.queries.GetCampaignLinkCounts.Select(&out, pq.Int64Array(ids), from, to); err != nil {
app.log.Printf("error fetching campaign %s: %v", typ, err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out})
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}

if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
}

out := make([]campCountStats, 0)
if err := stmt.Select(&out, pq.Int64Array(ids), from, to); err != nil {
app.log.Printf("error fetching campaign %s: %v", typ, err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}

return c.JSON(http.StatusOK, okResp{out})
}

// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
Expand Down Expand Up @@ -719,3 +777,21 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
o.Body = b.String()
return o, nil
}

// makeCampaignQuery cleans an optional campaign search string and prepares the
// campaign SQL statement (string) and returns them.
func makeCampaignQuery(q, orderBy, order, query string) (string, string) {
if q != "" {
q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
}

// Sort params.
if !strSliceContains(orderBy, campaignQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortDesc
}

return q, fmt.Sprintf(query, orderBy, order)
}
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
g.GET("/api/campaigns", handleGetCampaigns)
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
g.GET("/api/campaigns/:id", handleGetCampaigns)
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/content", handleCampaignContent)
Expand Down
39 changes: 33 additions & 6 deletions cmd/lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,47 @@ var (
listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
)

// handleGetLists handles retrieval of lists.
// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
func handleGetLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
out listsWrap

pg = getPagination(c.QueryParams(), 20)
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
listID, _ = strconv.Atoi(c.Param("id"))
single = false
pg = getPagination(c.QueryParams(), 20)
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
listID, _ = strconv.Atoi(c.Param("id"))
single = false
)

// Fetch one list.
if listID > 0 {
single = true
}

if !single && minimal {
// Minimal query simply returns the list of all lists with no additional metadata. This is fast.
if err := app.queries.GetLists.Select(&out.Results, ""); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
if len(out.Results) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}

// Meta.
out.Total = out.Results[0].Total
out.Page = 1
out.PerPage = out.Total
if out.PerPage == 0 {
out.PerPage = out.Total
}
return c.JSON(http.StatusOK, okResp{out})
}

// Sort params.
if !strSliceContains(orderBy, listQuerySortFields) {
orderBy = "created_at"
Expand Down Expand Up @@ -79,6 +102,10 @@ func handleGetLists(c echo.Context) error {
out.Total = out.Results[0].Total
out.Page = pg.Page
out.PerPage = pg.PerPage
if out.PerPage == 0 {
out.PerPage = out.Total
}

return c.JSON(http.StatusOK, okResp{out})
}

Expand Down
5 changes: 5 additions & 0 deletions cmd/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Queries struct {

// Non-prepared arbitrary subscriber queries.
QuerySubscribers string `query:"query-subscribers"`
QuerySubscribersCount string `query:"query-subscribers-count"`
QuerySubscribersForExport string `query:"query-subscribers-for-export"`
QuerySubscribersTpl string `query:"query-subscribers-template"`
DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"`
Expand All @@ -57,6 +58,10 @@ type Queries struct {
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
GetCampaignViewCounts *sqlx.Stmt `query:"get-campaign-view-counts"`
GetCampaignClickCounts *sqlx.Stmt `query:"get-campaign-click-counts"`
GetCampaignBounceCounts *sqlx.Stmt `query:"get-campaign-bounce-counts"`
GetCampaignLinkCounts *sqlx.Stmt `query:"get-campaign-link-counts"`
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`
Expand Down
36 changes: 25 additions & 11 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func handleQuerySubscribers(c echo.Context) error {
query = sanitizeSQLExp(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
out subsWrap
out = subsWrap{Results: make([]models.Subscriber, 0, 1)}
)

listIDs := pq.Int64Array{}
Expand All @@ -130,15 +130,15 @@ func handleQuerySubscribers(c echo.Context) error {

// Sort params.
if !strSliceContains(orderBy, subQuerySortFields) {
orderBy = "updated_at"
orderBy = "subscribers.id"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
order = sortDesc
}

stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)

// Create a readonly transaction to prevent mutations.
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
stmt := fmt.Sprintf(app.queries.QuerySubscribersCount, cond)
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
Expand All @@ -147,7 +147,21 @@ func handleQuerySubscribers(c echo.Context) error {
}
defer tx.Rollback()

// Run the query. stmt is the raw SQL query.
// Execute the readonly query and get the count of results.
var total = 0
if err := tx.Get(&total, stmt, listIDs); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}

// No results.
if total == 0 {
return c.JSON(http.StatusOK, okResp{out})
}

// Run the query again and fetch the actual data. stmt is the raw SQL query.
stmt = fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
Expand All @@ -169,7 +183,7 @@ func handleQuerySubscribers(c echo.Context) error {
}

// Meta.
out.Total = out.Results[0].Total
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage

Expand Down Expand Up @@ -409,7 +423,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
Expand Down Expand Up @@ -449,7 +463,7 @@ func handleManageSubscriberLists(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
Expand Down Expand Up @@ -505,7 +519,7 @@ func handleDeleteSubscribers(c echo.Context) error {
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
Expand Down
28 changes: 14 additions & 14 deletions frontend/fontello/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,20 @@
"email-bounce"
]
},
{
"uid": "fcb7bfb12b7533c7026762bfc328ca1c",
"css": "speedometer",
"code": 59430,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
"width": 1000
},
"search": [
"speedometer"
]
},
{
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
"css": "vector-square",
Expand Down Expand Up @@ -17478,20 +17492,6 @@
"speaker-off"
]
},
{
"uid": "fcb7bfb12b7533c7026762bfc328ca1c",
"css": "speedometer",
"code": 984261,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
"width": 1000
},
"search": [
"speedometer"
]
},
{
"uid": "d26bb53c36a567c6d3f5a87d8ce6accf",
"css": "spellcheck",
Expand Down
3 changes: 1 addition & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"dependencies": {
"axios": "^0.21.1",
"buefy": "^0.9.7",
"buefy": "^0.9.10",
"c3": "^0.7.20",
"codeflask": "^1.4.1",
"core-js": "^3.12.1",
Expand All @@ -22,7 +22,6 @@
"textversionjs": "^1.1.3",
"turndown": "^7.0.0",
"vue": "^2.6.12",
"vue-c3": "^1.2.11",
"vue-i18n": "^8.22.2",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0",
Expand Down
Binary file modified frontend/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates" data-cy="templates"
icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>

<b-menu-item :to="{name: 'campaignAnalytics'}" tag="router-link"
:active="activeItem.campaignAnalytics" data-cy="analytics"
icon="chart-bar" :label="$t('globals.terms.analytics')"></b-menu-item>
</b-menu-item><!-- campaigns -->

<b-menu-item :expanded="activeGroup.settings"
Expand Down Expand Up @@ -187,7 +191,7 @@ export default Vue.extend({
mounted() {
// Lists is required across different views. On app load, fetch the lists
// and have them in the store.
this.$api.getLists();
this.$api.getLists({ minimal: true });
},
});
</script>
Expand Down
Loading

0 comments on commit 4b127f1

Please sign in to comment.