Add campaign analytics APIs and UI

This commit is contained in:
Kailash Nadh 2021-09-11 12:57:55 +05:30
parent 3135bfc12a
commit 61e88681ed
24 changed files with 720 additions and 72 deletions

View file

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

View file

@ -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)

View file

@ -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"`

View file

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

View file

@ -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"

View file

@ -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 });

View file

@ -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 */
@ -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) {
@ -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;
@ -491,25 +519,22 @@ section.import {
/* Campaigns page */
section.campaigns {
table tbody {
tr.running {
background: lighten(#1890ff, 43%);
td {
border-bottom: 1px solid lighten(#1890ff, 30%);
}
.spinner {
margin-left: 10px;
}
.spinner .loading-overlay .loading-icon::after {
.spinner {
margin-left: 10px;
.loading-overlay .loading-icon::after {
border-bottom-color: lighten(#1890ff, 30%);
border-left-color: lighten(#1890ff, 30%);
}
}
td {
&.status .spinner {
margin-left: 10px;
tr.running {
background: lighten(#1890ff, 43%);
td {
border-bottom: 1px solid lighten(#1890ff, 30%);
}
}
td {
.tags {
margin-top: 5px;
}
@ -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 {
@ -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;
@ -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;

View file

@ -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',

View file

@ -78,6 +78,23 @@ export default class Utils {
return out.toFixed(2) + pfx;
}
// Parse one or more numeric ids as query params and return as an array of ints.
parseQueryIDs = (ids) => {
if (!ids) {
return [];
}
if (typeof ids === 'string') {
return [parseInt(ids, 10)];
}
if (typeof ids === 'number') {
return [parseInt(ids, 10)];
}
return ids.map((id) => parseInt(id, 10));
}
// https://stackoverflow.com/a/12034334
escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);

View file

@ -0,0 +1,429 @@
<template>
<section class="analytics content relative">
<h1 class="title is-4">{{ $t('analytics.title') }}</h1>
<hr />
<form @submit.prevent="onSubmit">
<div class="columns">
<div class="column is-6">
<b-field :label="$t('globals.terms.campaigns')" label-position="on-border">
<b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis
icon="tag-outline" :placeholder="$t('globals.terms.campaigns')"
autocomplete :allow-new="false" :before-adding="isCampaignSelected"
@typing="queryCampaigns" field="name" :loading="isSearchLoading"></b-taginput>
</b-field>
</div>
<div class="column is-5">
<div class="columns">
<div class="column is-6">
<b-field data-cy="from" :label="$t('analytics.fromDate')" label-position="on-border">
<b-datetimepicker
v-model="form.from"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onFromDateChange" />
</b-field>
</div>
<div class="column is-6">
<b-field data-cy="to" :label="$t('analytics.toDate')" label-position="on-border">
<b-datetimepicker
v-model="form.to"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" @input="onToDateChange" />
</b-field>
</div>
</div><!-- columns -->
</div><!-- columns -->
<div class="column is-1">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="form.campaigns.length === 0" data-cy="btn-search"></b-button>
</div>
</div><!-- columns -->
</form>
<section class="charts mt-5">
<div class="chart columns" v-for="(v, k) in charts" :key="k">
<div class="column is-9">
<b-loading v-if="v.loading" :active="v.loading" :is-full-page="false" />
<h4 v-if="v.chart !== null">{{ v.name }} ({{ counts[k] }})</h4>
<div :ref="`chart-${k}`" :id="`chart-${k}`"></div>
</div>
<div class="column is-2 donut-container">
<div :ref="`donut-${k}`" :id="`donut-${k}`" class="donut"></div>
</div>
</div>
</section>
</section>
</template>
<style lang="css">
@import "~c3/c3.css";
</style>
<script>
import Vue from 'vue';
import dayjs from 'dayjs';
import c3 from 'c3';
import { colors } from '../constants';
const chartColorRed = '#ee7d5b';
const chartColors = [
colors.primary,
'#FFB50D',
'#41AC9C',
chartColorRed,
'#7FC7BC',
'#3a82d6',
'#688ED9',
'#FFC43D',
];
export default Vue.extend({
data() {
return {
isSearchLoading: false,
queriedCampaigns: [],
// Data for each view.
counts: {
views: 0,
clicks: 0,
bounces: 0,
links: 0,
},
charts: {
views: {
name: this.$t('campaigns.views'),
data: [],
fn: this.$api.getCampaignViewCounts,
chart: null,
chartFn: this.processLines,
donut: null,
donutFn: this.renderDonutChart,
loading: false,
},
clicks: {
name: this.$t('campaigns.clicks'),
data: [],
fn: this.$api.getCampaignClickCounts,
chart: null,
chartFn: this.processLines,
donut: null,
donutFn: this.renderDonutChart,
loading: false,
},
bounces: {
name: this.$t('globals.terms.bounces'),
data: [],
fn: this.$api.getCampaignBounceCounts,
chart: null,
chartFn: this.processLines,
donut: null,
donutFn: this.renderDonutChart,
donutColor: chartColorRed,
loading: false,
},
links: {
name: this.$t('analytics.links'),
data: [],
chart: null,
loading: false,
fn: this.$api.getCampaignLinkCounts,
chartFn: this.renderLinksChart,
},
},
form: {
campaigns: [],
from: null,
to: null,
},
};
},
methods: {
formatDateTime(s) {
return dayjs(s).format('YYYY-MM-DD HH:mm');
},
isCampaignSelected(camp) {
return !this.form.campaigns.find(({ id }) => id === camp.id);
},
onFromDateChange() {
if (this.form.from > this.form.to) {
this.form.to = dayjs(this.form.from).add(7, 'day').toDate();
}
},
onToDateChange() {
if (this.form.from > this.form.to) {
this.form.from = dayjs(this.form.to).add(-7, 'day').toDate();
}
},
renderLineChart(typ, data, el) {
const conf = {
bindto: el,
unload: true,
data: {
type: 'spline',
xs: {},
columns: [],
names: [],
colors: {},
empty: { label: { text: this.$t('globals.messages.emptyState') } },
},
axis: {
x: {
type: 'timeseries',
tick: {
format: '%Y-%m-%d %H:%M',
},
},
},
legend: {
show: false,
},
};
// Add campaign data to the chart.
data.forEach((c, n) => {
if (c.data.length === 0) {
return;
}
const x = `x${n + 1}`;
const d = `data${n + 1}`;
// data1, data2, datan => x1, x2, xn.
conf.data.xs[d] = x;
// Campaign name for each datan.
conf.data.names[d] = c.name;
// Dates for each xn.
conf.data.columns.push([x, ...c.data.map((v) => dayjs(v.timestamp))]);
// Counts for each datan.
conf.data.columns.push([d, ...c.data.map((v) => v.count)]);
// Colours for each datan.
conf.data.colors[d] = chartColors[n % data.length];
});
this.$nextTick(() => {
if (this.charts[typ].chart) {
this.charts[typ].chart.destroy();
}
this.charts[typ].chart = c3.generate(conf);
});
},
renderDonutChart(typ, camps, data) {
const conf = {
bindto: this.$refs[`donut-${typ}`][0],
unload: true,
data: {
type: 'gauge',
columns: [],
},
gauge: {
width: 15,
max: 100,
},
color: {
pattern: [],
},
};
conf.gauge.max = camps.reduce((sum, c) => sum + c.sent, 0);
conf.data.columns.push([this.charts[typ].name, data.reduce((sum, d) => sum + d.count, 0)]);
conf.color.pattern.push(this.charts[typ].donutColor ?? chartColors[0]);
this.$nextTick(() => {
if (this.charts[typ].donut) {
this.charts[typ].donut.destroy();
}
if (conf.gauge.max > 0) {
this.charts[typ].donut = c3.generate(conf);
}
});
},
renderLinksChart(typ, camps, data) {
const conf = {
bindto: this.$refs[`chart-${typ}`][0],
unload: true,
data: {
type: 'bar',
x: 'x',
columns: [],
color: (c, d) => (typeof (d) === 'object' ? chartColors[d.index % data.length] : chartColors[0]),
empty: { label: { text: this.$t('globals.messages.emptyState') } },
onclick: (d) => {
window.open(data[d.index].url, '_blank', 'noopener noreferrer');
},
},
bar: {
width: {
max: 30,
},
},
axis: {
rotated: true,
x: {
type: 'category',
tick: {
multiline: false,
},
},
},
};
// Add link data to the chart.
// https://c3js.org/samples/axes_x_tick_rotate.html
conf.data.columns.push(['x', ...data.map((l) => {
try {
const u = new URL(l.url);
if (l.url.length > 80) {
return `${u.hostname}${u.pathname.substr(0, 50)}..`;
}
return u.hostname + u.pathname;
} catch {
return l.url;
}
})]);
conf.data.columns.push([this.$t('analytics.count'), ...data.map((l) => l.count)]);
this.$nextTick(() => {
if (this.charts[typ].chart) {
this.charts[typ].chart.destroy();
}
this.charts[typ].chart = c3.generate(conf);
});
},
processLines(typ, camps, data) {
// Make a campaign id => camp lookup map to group incoming
// data by campaigns.
const campIDs = camps.reduce((obj, c) => {
const out = { ...obj };
out[c.id] = c;
return out;
}, {});
// Group individual data points per campaign id.
// {1: [...], 2: [...]}
const groups = data.reduce((obj, d) => {
const out = { ...obj };
if (!(d.campaignId in out)) {
out[d.campaignId] = [];
}
out[d.campaignId].push(d);
return out;
}, {});
Object.keys(groups).forEach((k) => {
this.charts[typ].data.push({
name: campIDs[groups[k][0].campaignId].name,
data: groups[k],
});
});
this.$nextTick(() => {
this.renderLineChart(typ, this.charts[typ].data, this.$refs[`chart-${typ}`][0]);
});
},
onSubmit() {
// Fetch count for each analytics type (views, counts, bounces);
Object.keys(this.charts).forEach((k) => {
// Clear existing data.
this.charts[k].data = [];
// Fetch views, clicks, bounces for every campaign.
this.getData(k, this.form.campaigns);
});
},
queryCampaigns(q) {
this.isSearchLoading = true;
this.$api.getCampaigns({
query: q,
order_by: 'created_at',
order: 'DESC',
}).then((data) => {
this.isSearchLoading = false;
this.queriedCampaigns = data.results.map((c) => {
// Change the name to include the ID in the auto-suggest results.
const camp = c;
camp.name = `#${c.id}: ${c.name}`;
return camp;
});
});
},
getData(typ, camps) {
this.charts[typ].loading = true;
// Call the HTTP API.
this.charts[typ].fn({
id: camps.map((c) => c.id),
from: this.form.from,
to: this.form.to,
}).then((data) => {
// Set the total count.
this.counts[typ] = data.reduce((sum, d) => sum + d.count, 0);
this.charts[typ].chartFn(typ, camps, data);
if (this.charts[typ].donutFn) {
this.charts[typ].donutFn(typ, camps, data);
}
this.charts[typ].loading = false;
});
},
},
created() {
const now = dayjs().set('hour', 23).set('minute', 59).set('seconds', 0);
this.form.to = now.toDate();
this.form.from = now.subtract(7, 'day').set('hour', 0).set('minute', 0).toDate();
},
mounted() {
// Fetch one or more campaigns if there are ?id params, wait for the fetches
// to finish, add them to the campaign selector and submit the form.
const ids = this.$utils.parseQueryIDs(this.$route.query.id);
if (ids.length > 0) {
this.isSearchLoading = true;
Promise.allSettled(ids.map((id) => this.$api.getCampaign(id))).then((data) => {
data.forEach((d) => {
if (d.status !== 'fulfilled') {
return;
}
const camp = d.value;
camp.name = `#${camp.id}: ${camp.name}`;
this.form.campaigns.push(camp);
});
this.$nextTick(() => {
this.isSearchLoading = false;
this.onSubmit();
});
});
}
},
});
</script>

View file

@ -29,7 +29,7 @@
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
hoverable backend-sorting @sort="onSort">
<b-table-column v-slot="props" class="status" field="status"
<b-table-column v-slot="props" cell-class="status" field="status"
:label="$t('globals.fields.status')" width="10%" sortable
:td-attrs="$utils.tdID" header-class="cy-status">
<div>
@ -70,9 +70,9 @@
</b-taglist>
</div>
</b-table-column>
<b-table-column v-slot="props" class="lists" field="lists"
<b-table-column v-slot="props" cell-class="lists" field="lists"
:label="$t('globals.terms.lists')" width="15%">
<ul class="no">
<ul>
<li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
{{ l.name }}
@ -103,7 +103,7 @@
</div>
</b-table-column>
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="18%">
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="15%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label>{{ $t('campaigns.views') }}</label>
@ -140,8 +140,9 @@
</div>
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" width="13%" align="right">
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
<div>
<!-- start / pause / resume / scheduled -->
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
@ -170,6 +171,25 @@
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<!-- placeholder for finished campaigns -->
<a v-if="!canCancel(props.row)
&& !canSchedule(props.row) && !canStart(props.row)" data-disabled>
<b-icon icon="rocket-launch-outline" size="is-small" />
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a v-else data-disabled>
<b-icon icon="cancel" size="is-small" />
</a>
<a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
@ -184,14 +204,11 @@
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
<router-link :to="{ name: 'campaignAnalytics', query: { 'id': props.row.id }}">
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
<b-icon icon="chart-bar" size="is-small" />
</b-tooltip>
</a>
</router-link>
<a href=""
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
() => deleteCampaign(props.row))" data-cy="btn-delete">

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail již existuje.",
"subscribers.errorBlocklisting": "Chyba při uvádění odběratelů na seznam blokovaných: {error}",
"subscribers.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
"globals.messages.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
"subscribers.errorNoIDs": "Nejsou uvedena žádná ID.",
"subscribers.errorNoListsGiven": "Nejsou uvedeny žádné seznamy.",
"subscribers.errorPreparingQuery": "Chyba při přípravě dotazu na odběratele: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-Mail",
"subscribers.emailExists": "E-Mail existiert bereits.",
"subscribers.errorBlocklisting": "Fehler. Abonnement ist geblockt: {error}",
"subscribers.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
"globals.messages.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
"subscribers.errorNoIDs": "Keine IDs angegeben.",
"subscribers.errorNoListsGiven": "Keine Listen angegeben.",
"subscribers.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",

View file

@ -1,6 +1,12 @@
{
"_.code": "en",
"_.name": "English (en)",
"analytics.title": "Analytics",
"analytics.fromDate": "From",
"analytics.toDate": "To",
"analytics.count": "Count",
"analytics.invalidDates": "Invalid `from` or `to` dates.",
"analytics.links": "Links",
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
"bounces.source": "Source",
"bounces.unknownService": "Unknown service.",
@ -160,6 +166,7 @@
"globals.months.7": "Jul",
"globals.months.8": "Aug",
"globals.months.9": "Sep",
"globals.terms.analytics": "Analytics",
"globals.terms.bounce": "Bounce | Bounces",
"globals.terms.bounces": "Bounces",
"globals.terms.campaign": "Campaign | Campaigns",
@ -427,7 +434,8 @@
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail already exists.",
"subscribers.errorBlocklisting": "Error blocklisting subscribers: {error}",
"subscribers.errorInvalidIDs": "One or more invalid IDs given: {error}",
"globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
"globals.messages.missingFields": "Missing field(s): {name}",
"subscribers.errorNoIDs": "No IDs given.",
"subscribers.errorNoListsGiven": "No lists given.",
"subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "Correo electrónico",
"subscribers.emailExists": "El correo electrónico ya existe.",
"subscribers.errorBlocklisting": "Error blocklisting subscriptrores: {error}",
"subscribers.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
"globals.messages.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
"subscribers.errorNoIDs": "No se ingresaron IDs.",
"subscribers.errorNoListsGiven": "No se ingresaron listas.",
"subscribers.errorPreparingQuery": "Error preparando la consulta del subscriptor: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "Email",
"subscribers.emailExists": "Cet email existe déjà.",
"subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}",
"subscribers.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
"globals.messages.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
"subscribers.errorNoIDs": "Aucun identifiant fourni.",
"subscribers.errorNoListsGiven": "Aucune liste attribuée.",
"subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "Email",
"subscribers.emailExists": "Email già esistente.",
"subscribers.errorBlocklisting": "Errore durante il blocco degli iscritti: {error}",
"subscribers.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
"globals.messages.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
"subscribers.errorNoIDs": "Nessun ID fornito.",
"subscribers.errorNoListsGiven": "Nessuna lista fornita.",
"subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "ഇ-മെയിൽ",
"subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
"subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
"subscribers.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
"globals.messages.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
"subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
"subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
"subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "Email",
"subscribers.emailExists": "Email już istnieje.",
"subscribers.errorBlocklisting": "Błąd blokowania subskrybentów: {error}",
"subscribers.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
"globals.messages.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
"subscribers.errorNoIDs": "Nie podano identyfikatorów.",
"subscribers.errorNoListsGiven": "Nie podano list.",
"subscribers.errorPreparingQuery": "Błąd przygotowywania zapytania o subskrypcje: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail já existe.",
"subscribers.errorBlocklisting": "Erro ao bloquear inscritos: {error}",
"subscribers.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
"globals.messages.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
"subscribers.errorNoIDs": "Nenhum ID informado.",
"subscribers.errorNoListsGiven": "Nenhuma lista informada.",
"subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail já existe.",
"subscribers.errorBlocklisting": "Erro ao bloquear subscritores: {error}",
"subscribers.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
"globals.messages.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
"subscribers.errorNoIDs": "Não foram dados IDs.",
"subscribers.errorNoListsGiven": "Não foram dadas listas.",
"subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail существует.",
"subscribers.errorBlocklisting": "Ошибка блокировки подписчиков: {error}",
"subscribers.errorInvalidIDs": "Указан один или более неверных ID: {error}",
"globals.messages.errorInvalidIDs": "Указан один или более неверных ID: {error}",
"subscribers.errorNoIDs": "Не указано ни одного ID.",
"subscribers.errorNoListsGiven": "Не указано ни одного списка.",
"subscribers.errorPreparingQuery": "Ошибка подготовки запроса подписчиков: {error}",

View file

@ -427,7 +427,7 @@
"subscribers.email": "E-posta",
"subscribers.emailExists": "E-posta zaten mevcut.",
"subscribers.errorBlocklisting": "Hata, erişime engelli üyeleri gösterme: {error}",
"subscribers.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
"globals.messages.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
"subscribers.errorNoIDs": "Herhangi bir ID verilmedi.",
"subscribers.errorNoListsGiven": "Liste tanımı yapılmamış.",
"subscribers.errorPreparingQuery": "Hata, üye sorgusu hazırlarken: {error}",

View file

@ -544,6 +544,43 @@ u AS (
)
SELECT * FROM camps;
-- name: get-campaign-view-counts
WITH intval AS (
-- For intervals < a week, aggregate counts hourly, otherwise daily.
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
)
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
FROM campaign_views
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
-- name: get-campaign-click-counts
WITH intval AS (
-- For intervals < a week, aggregate counts hourly, otherwise daily.
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
)
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
FROM link_clicks
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
-- name: get-campaign-bounce-counts
WITH intval AS (
-- For intervals < a week, aggregate counts hourly, otherwise daily.
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
)
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
FROM bounces
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
-- name: get-campaign-link-counts
SELECT COUNT(*) AS "count", url
FROM link_clicks
LEFT JOIN links ON (link_clicks.link_id = links.id)
WHERE campaign_id=ANY($1) AND link_clicks.created_at >= $2 AND link_clicks.created_at <= $3
GROUP BY links.url ORDER BY "count" DESC LIMIT 50;
-- name: next-campaign-subscribers
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means