Skip to content

Commit

Permalink
Add campaign analytics APIs and UI
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Sep 17, 2021
1 parent 3135bfc commit 61e8868
Show file tree
Hide file tree
Showing 24 changed files with 718 additions and 70 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
4 changes: 4 additions & 0 deletions cmd/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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
6 changes: 3 additions & 3 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,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 +449,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 +505,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
4 changes: 4 additions & 0 deletions 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.analytics" 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
12 changes: 12 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ export const getCampaignStats = async () => http.get('/api/campaigns/running/sta
export const createCampaign = async (data) => http.post('/api/campaigns', data,
{ loading: models.campaigns });

export const getCampaignViewCounts = async (params) => http.get('/api/campaigns/analytics/views',
{ params, loading: models.campaigns });

export const getCampaignClickCounts = async (params) => http.get('/api/campaigns/analytics/clicks',
{ params, loading: models.campaigns });

export const getCampaignBounceCounts = async (params) => http.get('/api/campaigns/analytics/bounces',
{ params, loading: models.campaigns });

export const getCampaignLinkCounts = async (params) => http.get('/api/campaigns/analytics/links',
{ params, loading: models.campaigns });

export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
{ loading: models.campaigns });

Expand Down
95 changes: 66 additions & 29 deletions frontend/src/assets/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,17 @@ body.is-noscroll .b-sidebar {
padding: 15px 10px;
border-color: $grey-lightest;
}

.actions a, .actions .a {
margin: 0 10px;
display: inline-block;
}
.actions a[data-disabled],
.actions .icon[data-disabled] {
pointer-events: none;
cursor: not-allowed;
color: $grey-light;
}
}

/* Modal */
Expand Down Expand Up @@ -294,16 +301,37 @@ body.is-noscroll .b-sidebar {
}
}

.autocomplete .dropdown-content {
background-color: $white-ter;
.autocomplete {
.dropdown-content {
background-color: $white-bis;
}
a.dropdown-item {
&:hover, &.is-hovered {
background-color: $grey-lightest;
color: $primary;
}
}
}

.input, .taginput .taginput-container.is-focusable, .textarea {
// box-shadow: inset 2px 2px 0px $white-ter;
box-shadow: 2px 2px 0 $white-ter;
border: 1px solid $grey-lighter;
}

.input {
height: auto;
padding: 10px 12px;
}
.control.has-icons-left .icon.is-left {
height: 3rem;
}

.button {
height: auto;
padding: 10px 20px;
}


/* Form fields */
.field {
&:not(:last-child) {
Expand Down Expand Up @@ -368,10 +396,10 @@ body.is-noscroll .b-sidebar {
}
&.public, &.running {
$color: $primary;
color: $color;
color: lighten($color, 20%);;
background: #e6f7ff;
border: 1px solid lighten($color, 37%);
box-shadow: 1px 1px 0 lighten($color, 25%);
border: 1px solid lighten($color, 42%);
box-shadow: 1px 1px 0 lighten($color, 42%);
}
&.finished, &.enabled {
$color: $green;
Expand Down Expand Up @@ -491,25 +519,22 @@ section.import {
/* Campaigns page */
section.campaigns {
table tbody {
.spinner {
margin-left: 10px;
.loading-overlay .loading-icon::after {
border-bottom-color: lighten(#1890ff, 30%);
border-left-color: lighten(#1890ff, 30%);
}
}

tr.running {
background: lighten(#1890ff, 43%);
td {
border-bottom: 1px solid lighten(#1890ff, 30%);
}

.spinner {
margin-left: 10px;
}
.spinner .loading-overlay .loading-icon::after {
border-bottom-color: lighten(#1890ff, 30%);
border-left-color: lighten(#1890ff, 30%);
}
}

td {
&.status .spinner {
margin-left: 10px;
}
.tags {
margin-top: 5px;
}
Expand All @@ -519,15 +544,8 @@ section.campaigns {
}

&.lists ul {
font-size: $size-7;
// font-size: $size-7;
list-style-type: circle;

a {
color: $grey-dark;
&:hover {
color: $primary;
}
}
}

.fields {
Expand Down Expand Up @@ -555,6 +573,26 @@ section.campaigns {
}
}

section.analytics {
.charts {
position: relative;
min-height: 100px;
}

.chart {
margin-bottom: 45px;
}

.donut-container {
position: relative;
}
.donut {
bottom: 0px;
right: 0px;
position: absolute !important;
}
}

/* Campaign / template preview popup */
.preview {
padding: 0;
Expand Down Expand Up @@ -702,11 +740,10 @@ section.campaign {
}

.c3-tooltip {
border: 0;
background-color: #fff;
@extend .box;
padding: 10px;
empty-cells: show;
box-shadow: none;
opacity: 0.9;
opacity: 0.95;

tr {
border: 0;
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ const routes = [
meta: { title: 'Templates', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
},
{
path: '/campaigns/analytics',
name: 'campaignAnalytics',
meta: { title: 'Campaign analytics', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/CampaignAnalytics.vue'),
},
{
path: '/campaigns/:id',
name: 'campaign',
Expand Down
Loading

0 comments on commit 61e8868

Please sign in to comment.