Add new dashboard (with new metrics)
This commit is contained in:
parent
97583fe4b4
commit
feb5ba09be
29
admin.go
29
admin.go
|
@ -17,10 +17,6 @@ type configScript struct {
|
|||
Messengers []string `json:"messengers"`
|
||||
}
|
||||
|
||||
type dashboardStats struct {
|
||||
Stats types.JSONText `db:"stats"`
|
||||
}
|
||||
|
||||
// handleGetConfigScript returns general configuration as a Javascript
|
||||
// variable that can be included in an HTML page directly.
|
||||
func handleGetConfigScript(c echo.Context) error {
|
||||
|
@ -41,17 +37,32 @@ func handleGetConfigScript(c echo.Context) error {
|
|||
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
|
||||
}
|
||||
|
||||
// handleGetDashboardStats returns general states for the dashboard.
|
||||
func handleGetDashboardStats(c echo.Context) error {
|
||||
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
|
||||
func handleGetDashboardCharts(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out dashboardStats
|
||||
out types.JSONText
|
||||
)
|
||||
|
||||
if err := app.queries.GetDashboardStats.Get(&out); err != nil {
|
||||
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out.Stats})
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetDashboardCounts returns stats counts to show on the dashboard.
|
||||
func handleGetDashboardCounts(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out types.JSONText
|
||||
)
|
||||
|
||||
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"axios": "^0.19.2",
|
||||
"buefy": "^0.8.20",
|
||||
"c3": "^0.7.18",
|
||||
"core-js": "^3.6.5",
|
||||
"dayjs": "^1.8.28",
|
||||
"humps": "^2.0.1",
|
||||
|
@ -20,6 +21,7 @@
|
|||
"quill-delta": "^4.2.2",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-c3": "^1.2.11",
|
||||
"vue-quill-editor": "^3.0.6",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuex": "^3.4.0"
|
||||
|
|
|
@ -79,6 +79,13 @@ http.interceptors.response.use((resp) => {
|
|||
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
|
||||
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
|
||||
|
||||
// Dashboard
|
||||
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
|
||||
{ loading: models.dashboard });
|
||||
|
||||
export const getDashboardCharts = () => http.get('/api/dashboard/charts',
|
||||
{ loading: models.dashboard });
|
||||
|
||||
// Lists.
|
||||
export const getLists = () => http.get('/api/lists',
|
||||
{ loading: models.lists, store: models.lists });
|
||||
|
|
|
@ -111,7 +111,7 @@ section {
|
|||
margin-right: 0;
|
||||
}
|
||||
> li {
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
.logo {
|
||||
|
@ -229,20 +229,34 @@ section {
|
|||
|
||||
/* Dashboard */
|
||||
section.dashboard {
|
||||
.counts {
|
||||
.title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.level-item {
|
||||
background-color: $white-bis;
|
||||
padding: 30px;
|
||||
margin: 10px;
|
||||
.title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&:first-child, &:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
.level-item {
|
||||
background-color: $white-bis;
|
||||
padding: 30px;
|
||||
margin: 10px;
|
||||
|
||||
&:first-child, &:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.charts {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists page */
|
||||
|
@ -429,6 +443,39 @@ section.campaign {
|
|||
}
|
||||
}
|
||||
|
||||
.c3 {
|
||||
.c3-chart-lines .c3-line {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.c3-axis-x .tick line,
|
||||
.c3-axis-y .tick line {
|
||||
display: none;
|
||||
}
|
||||
text {
|
||||
fill: $grey;
|
||||
font-family: $body-family;
|
||||
font-size: 11px;
|
||||
}
|
||||
.c3-axis path, .c3-axis line {
|
||||
stroke: #eee;
|
||||
}
|
||||
|
||||
.c3-tooltip {
|
||||
border: 0;
|
||||
background-color: #fff;
|
||||
empty-cells: show;
|
||||
box-shadow: none;
|
||||
opacity: 0.9;
|
||||
|
||||
tr {
|
||||
border: 0;
|
||||
}
|
||||
th {
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1450px) and (min-width: 769px) {
|
||||
section.campaigns {
|
||||
/* Fold the stats labels until the card view */
|
||||
|
@ -478,3 +525,9 @@ section.campaign {
|
|||
margin: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
section.dashboard label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
|
||||
</p>
|
||||
<p>{{ !label ? 'Nothing here yet' : label }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EmptyPlaceholder',
|
||||
props: {
|
||||
icon: String,
|
||||
label: String,
|
||||
},
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
export const models = Object.freeze({
|
||||
dashboard: 'dashboard',
|
||||
lists: 'lists',
|
||||
subscribers: 'subscribers',
|
||||
campaigns: 'campaigns',
|
||||
|
@ -20,3 +21,7 @@ export const storeKeys = Object.freeze({
|
|||
});
|
||||
|
||||
export const timestamp = 'ddd D MMM YYYY, hh:mm A';
|
||||
|
||||
export const colors = Object.freeze({
|
||||
primary: '#7f2aff',
|
||||
});
|
||||
|
|
|
@ -31,6 +31,10 @@ export default class utils {
|
|||
static validateEmail = (e) => e.match(reEmail);
|
||||
|
||||
static niceNumber = (n) => {
|
||||
if (n === null || n === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let pfx = '';
|
||||
let div = 1;
|
||||
|
||||
|
|
|
@ -4,55 +4,215 @@
|
|||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-5">{{ dayjs().format("ddd, DD MMM") }}</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="columns counts">
|
||||
<div class="column is-half">
|
||||
<div class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Subscribers</p>
|
||||
<section class="counts wrap-small">
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-vertical is-12">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-vertical">
|
||||
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Lists</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
|
||||
single opt-in
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
|
||||
double opt-in</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- lists -->
|
||||
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Campaigns</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
|
||||
<label>{{ num }}</label> {{ status }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- campaigns -->
|
||||
</div><!-- block -->
|
||||
|
||||
<div class="tile is-parent">
|
||||
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Subscribers</p>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.subscribers.blacklisted) }}</label>
|
||||
blacklisted
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
|
||||
orphans
|
||||
</li>
|
||||
</ul>
|
||||
</div><!-- subscriber breakdown -->
|
||||
</div><!-- subscriber columns -->
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Messages sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- subscribers -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Lists</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Messages sent</p>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<b-loading v-if="isChartsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification charts">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<h3 class="title is-size-6 has-text-right">Campaign views</h3>
|
||||
<vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
|
||||
<empty-placeholder v-else-if="!isChartsLoading" />
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<h3 class="title is-size-6 has-text-right">Link clicks</h3>
|
||||
<vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
|
||||
<empty-placeholder v-else-if="!isChartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- tile block -->
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="css">
|
||||
@import "~c3/c3.css";
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import VueC3 from 'vue-c3';
|
||||
import dayjs from 'dayjs';
|
||||
import { colors } from '../constants';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Home',
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
VueC3,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Unique Vue() instances for each chart.
|
||||
chartViewsInst: null,
|
||||
chartClicksInst: null,
|
||||
|
||||
isChartsLoading: true,
|
||||
isCountsLoading: true,
|
||||
|
||||
counts: {
|
||||
lists: {},
|
||||
subscribers: {},
|
||||
campaigns: {},
|
||||
messages: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
makeChart(label, data) {
|
||||
const conf = {
|
||||
data: {
|
||||
columns: [
|
||||
[label, ...data.map((d) => d.count).reverse()],
|
||||
],
|
||||
type: 'spline',
|
||||
color() {
|
||||
return colors.primary;
|
||||
},
|
||||
},
|
||||
axis: {
|
||||
x: {
|
||||
type: 'category',
|
||||
categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
|
||||
tick: {
|
||||
rotate: -45,
|
||||
multiline: false,
|
||||
culling: { max: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
return conf;
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
dayjs() {
|
||||
return dayjs;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Pull the counts.
|
||||
this.$api.getDashboardCounts().then((r) => {
|
||||
this.counts = r.data;
|
||||
this.isCountsLoading = false;
|
||||
});
|
||||
|
||||
// Pull the charts.
|
||||
this.$api.getDashboardCharts().then((r) => {
|
||||
this.isChartsLoading = false;
|
||||
|
||||
// vue-c3 lib requires unique instances of Vue() to communicate.
|
||||
if (r.data.campaignViews.length > 0) {
|
||||
this.chartViewsInst = this;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.chartViewsInst.$emit('init',
|
||||
this.makeChart('Campaign views', r.data.campaignViews));
|
||||
});
|
||||
}
|
||||
|
||||
if (r.data.linkClicks.length > 0) {
|
||||
this.chartClicksInst = new Vue();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.chartClicksInst.$emit('init',
|
||||
this.makeChart('Link clicks', r.data.linkClicks));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="subscribers" label="Subscribers" numeric sortable centered>
|
||||
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
|
||||
<router-link :to="`/subscribers/lists/${props.row.id}`">
|
||||
{{ props.row.subscriberCount }}
|
||||
</router-link>
|
||||
|
@ -78,7 +78,7 @@
|
|||
<p>
|
||||
<b-icon icon="plus" size="is-large" />
|
||||
</p>
|
||||
<p>Nothing here.</p>
|
||||
<p>Nothing here yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
@ -1992,6 +1992,13 @@ bytes@3.1.0:
|
|||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
||||
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
|
||||
|
||||
c3@^0.7.18:
|
||||
version "0.7.18"
|
||||
resolved "https://registry.yarnpkg.com/c3/-/c3-0.7.18.tgz#a94228191b6178288fa416a6135c9624abd8dc45"
|
||||
integrity sha512-ioiqCvET2sjAn80V3qVBEkyAtCH3tktZsz9SylGmUeeGEfqZxZfq9qRCxfgl64LpA6d9/Oz4C8oYEIXHbMX/Ow==
|
||||
dependencies:
|
||||
d3 "^5.8.0"
|
||||
|
||||
cacache@^12.0.2, cacache@^12.0.3:
|
||||
version "12.0.4"
|
||||
resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
|
||||
|
@ -2414,16 +2421,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
|
|||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
commander@2, commander@^2.18.0, commander@^2.20.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@2.17.x:
|
||||
version "2.17.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
|
||||
integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
|
||||
|
||||
commander@^2.18.0, commander@^2.20.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@~2.19.0:
|
||||
version "2.19.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
|
||||
|
@ -2863,6 +2870,254 @@ cyclist@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
|
||||
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
|
||||
|
||||
d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
||||
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
|
||||
|
||||
d3-axis@1:
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
|
||||
integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
|
||||
|
||||
d3-brush@1:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-1.1.5.tgz#066b8e84d17b192986030446c97c0fba7e1bacdc"
|
||||
integrity sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==
|
||||
dependencies:
|
||||
d3-dispatch "1"
|
||||
d3-drag "1"
|
||||
d3-interpolate "1"
|
||||
d3-selection "1"
|
||||
d3-transition "1"
|
||||
|
||||
d3-chord@1:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-1.0.6.tgz#309157e3f2db2c752f0280fedd35f2067ccbb15f"
|
||||
integrity sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==
|
||||
dependencies:
|
||||
d3-array "1"
|
||||
d3-path "1"
|
||||
|
||||
d3-collection@1:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
|
||||
integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
|
||||
|
||||
d3-color@1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
|
||||
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
|
||||
|
||||
d3-contour@1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3"
|
||||
integrity sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==
|
||||
dependencies:
|
||||
d3-array "^1.1.1"
|
||||
|
||||
d3-dispatch@1:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
|
||||
integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
|
||||
|
||||
d3-drag@1:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
|
||||
integrity sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==
|
||||
dependencies:
|
||||
d3-dispatch "1"
|
||||
d3-selection "1"
|
||||
|
||||
d3-dsv@1:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c"
|
||||
integrity sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==
|
||||
dependencies:
|
||||
commander "2"
|
||||
iconv-lite "0.4"
|
||||
rw "1"
|
||||
|
||||
d3-ease@1:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.6.tgz#ebdb6da22dfac0a22222f2d4da06f66c416a0ec0"
|
||||
integrity sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ==
|
||||
|
||||
d3-fetch@1:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-1.2.0.tgz#15ce2ecfc41b092b1db50abd2c552c2316cf7fc7"
|
||||
integrity sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==
|
||||
dependencies:
|
||||
d3-dsv "1"
|
||||
|
||||
d3-force@1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.2.1.tgz#fd29a5d1ff181c9e7f0669e4bd72bdb0e914ec0b"
|
||||
integrity sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==
|
||||
dependencies:
|
||||
d3-collection "1"
|
||||
d3-dispatch "1"
|
||||
d3-quadtree "1"
|
||||
d3-timer "1"
|
||||
|
||||
d3-format@1:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.4.tgz#356925f28d0fd7c7983bfad593726fce46844030"
|
||||
integrity sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==
|
||||
|
||||
d3-geo@1:
|
||||
version "1.12.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.1.tgz#7fc2ab7414b72e59fbcbd603e80d9adc029b035f"
|
||||
integrity sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==
|
||||
dependencies:
|
||||
d3-array "1"
|
||||
|
||||
d3-hierarchy@1:
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
|
||||
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
|
||||
|
||||
d3-interpolate@1:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
|
||||
integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
|
||||
dependencies:
|
||||
d3-color "1"
|
||||
|
||||
d3-path@1:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
|
||||
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
|
||||
|
||||
d3-polygon@1:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
|
||||
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
|
||||
|
||||
d3-quadtree@1:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135"
|
||||
integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==
|
||||
|
||||
d3-random@1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291"
|
||||
integrity sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==
|
||||
|
||||
d3-scale-chromatic@1:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98"
|
||||
integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==
|
||||
dependencies:
|
||||
d3-color "1"
|
||||
d3-interpolate "1"
|
||||
|
||||
d3-scale@2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
|
||||
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
|
||||
dependencies:
|
||||
d3-array "^1.2.0"
|
||||
d3-collection "1"
|
||||
d3-format "1"
|
||||
d3-interpolate "1"
|
||||
d3-time "1"
|
||||
d3-time-format "2"
|
||||
|
||||
d3-selection@1, d3-selection@^1.1.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.1.tgz#98eedbbe085fbda5bafa2f9e3f3a2f4d7d622a98"
|
||||
integrity sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA==
|
||||
|
||||
d3-shape@1:
|
||||
version "1.3.7"
|
||||
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
|
||||
integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==
|
||||
dependencies:
|
||||
d3-path "1"
|
||||
|
||||
d3-time-format@2:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.2.3.tgz#0c9a12ee28342b2037e5ea1cf0b9eb4dd75f29cb"
|
||||
integrity sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==
|
||||
dependencies:
|
||||
d3-time "1"
|
||||
|
||||
d3-time@1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
|
||||
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
|
||||
|
||||
d3-timer@1:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
|
||||
integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
|
||||
|
||||
d3-transition@1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
|
||||
integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==
|
||||
dependencies:
|
||||
d3-color "1"
|
||||
d3-dispatch "1"
|
||||
d3-ease "1"
|
||||
d3-interpolate "1"
|
||||
d3-selection "^1.1.0"
|
||||
d3-timer "1"
|
||||
|
||||
d3-voronoi@1:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"
|
||||
integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==
|
||||
|
||||
d3-zoom@1:
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.8.3.tgz#b6a3dbe738c7763121cd05b8a7795ffe17f4fc0a"
|
||||
integrity sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==
|
||||
dependencies:
|
||||
d3-dispatch "1"
|
||||
d3-drag "1"
|
||||
d3-interpolate "1"
|
||||
d3-selection "1"
|
||||
d3-transition "1"
|
||||
|
||||
d3@^5.8.0:
|
||||
version "5.16.0"
|
||||
resolved "https://registry.yarnpkg.com/d3/-/d3-5.16.0.tgz#9c5e8d3b56403c79d4ed42fbd62f6113f199c877"
|
||||
integrity sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==
|
||||
dependencies:
|
||||
d3-array "1"
|
||||
d3-axis "1"
|
||||
d3-brush "1"
|
||||
d3-chord "1"
|
||||
d3-collection "1"
|
||||
d3-color "1"
|
||||
d3-contour "1"
|
||||
d3-dispatch "1"
|
||||
d3-drag "1"
|
||||
d3-dsv "1"
|
||||
d3-ease "1"
|
||||
d3-fetch "1"
|
||||
d3-force "1"
|
||||
d3-format "1"
|
||||
d3-geo "1"
|
||||
d3-hierarchy "1"
|
||||
d3-interpolate "1"
|
||||
d3-path "1"
|
||||
d3-polygon "1"
|
||||
d3-quadtree "1"
|
||||
d3-random "1"
|
||||
d3-scale "2"
|
||||
d3-scale-chromatic "1"
|
||||
d3-selection "1"
|
||||
d3-shape "1"
|
||||
d3-time "1"
|
||||
d3-time-format "2"
|
||||
d3-timer "1"
|
||||
d3-transition "1"
|
||||
d3-voronoi "1"
|
||||
d3-zoom "1"
|
||||
|
||||
dashdash@^1.12.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
|
||||
|
@ -4504,7 +4759,7 @@ humps@^2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
|
||||
integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=
|
||||
|
||||
iconv-lite@0.4.24, iconv-lite@^0.4.24:
|
||||
iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
|
||||
|
@ -7397,6 +7652,11 @@ run-queue@^1.0.0, run-queue@^1.0.3:
|
|||
dependencies:
|
||||
aproba "^1.1.1"
|
||||
|
||||
rw@1:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
|
||||
integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
|
||||
|
||||
rxjs@^6.5.3:
|
||||
version "6.5.5"
|
||||
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
|
||||
|
@ -8646,6 +8906,11 @@ vm-browserify@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
|
||||
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
|
||||
|
||||
vue-c3@^1.2.11:
|
||||
version "1.2.11"
|
||||
resolved "https://registry.yarnpkg.com/vue-c3/-/vue-c3-1.2.11.tgz#6937f0dd54addab2b76de74cd30c0ab9ad788080"
|
||||
integrity sha512-jxYZ726lKO1Qa+CHOcekPD4ZIwcMQy2LYDafYy2jYD1oswAo/4SnEJmbwp9X+NWzZg/KIAijeB9ImS7Gfvhceg==
|
||||
|
||||
vue-eslint-parser@^7.0.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.1.0.tgz#9cdbcc823e656b087507a1911732b867ac101e83"
|
||||
|
|
|
@ -38,7 +38,8 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
|
|||
func registerHTTPHandlers(e *echo.Echo) {
|
||||
e.GET("/", handleIndexPage)
|
||||
e.GET("/api/config.js", handleGetConfigScript)
|
||||
e.GET("/api/dashboard/stats", handleGetDashboardStats)
|
||||
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
||||
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
||||
|
||||
e.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
|
|
3
init.go
3
init.go
|
@ -87,10 +87,13 @@ func initFS(staticDir string) stuffbin.FileSystem {
|
|||
// initDB initializes the main DB connection pool and parse and loads the app's
|
||||
// SQL queries into a prepared query map.
|
||||
func initDB() *sqlx.DB {
|
||||
|
||||
var dbCfg dbConf
|
||||
if err := ko.Unmarshal("db", &dbCfg); err != nil {
|
||||
lo.Fatalf("error loading db config: %v", err)
|
||||
}
|
||||
|
||||
lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
|
||||
db, err := connectDB(dbCfg)
|
||||
if err != nil {
|
||||
lo.Fatalf("error connecting to DB: %v", err)
|
||||
|
|
|
@ -11,7 +11,8 @@ import (
|
|||
|
||||
// Queries contains all prepared SQL queries.
|
||||
type Queries struct {
|
||||
GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"`
|
||||
GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
|
||||
GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
|
||||
|
||||
InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"`
|
||||
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
|
||||
|
|
51
queries.sql
51
queries.sql
|
@ -678,21 +678,8 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
|
|||
RETURNING (SELECT url FROM link);
|
||||
|
||||
|
||||
-- name: get-dashboard-stats
|
||||
WITH lists AS (
|
||||
SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row
|
||||
),
|
||||
subs AS (
|
||||
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row
|
||||
),
|
||||
orphans AS (
|
||||
SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
||||
WHERE subscriber_lists.subscriber_id IS NULL
|
||||
),
|
||||
camps AS (
|
||||
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row
|
||||
),
|
||||
clicks AS (
|
||||
-- name: get-dashboard-charts
|
||||
WITH clicks AS (
|
||||
-- Clicks by day for the last 3 months
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (SELECT COUNT(*) AS count, created_at::DATE as date
|
||||
|
@ -706,9 +693,31 @@ views AS (
|
|||
FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
|
||||
) row
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'),
|
||||
'subscribers', COALESCE((SELECT * FROM subs), '[]'),
|
||||
'orphan_subscribers', (SELECT * FROM orphans),
|
||||
'campaigns', COALESCE((SELECT * FROM camps), '[]'),
|
||||
'link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;
|
||||
SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]'));
|
||||
|
||||
-- name: get-dashboard-counts
|
||||
SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM subscribers),
|
||||
'blacklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blacklisted'),
|
||||
'orphans', (
|
||||
SELECT COUNT(id) FROM subscribers
|
||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
||||
WHERE subscriber_lists.subscriber_id IS NULL
|
||||
)
|
||||
),
|
||||
'lists', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM lists),
|
||||
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
|
||||
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
|
||||
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
|
||||
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
|
||||
),
|
||||
'campaigns', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM campaigns),
|
||||
'by_status', (
|
||||
SELECT JSON_OBJECT_AGG (status, num) FROM
|
||||
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
|
||||
)
|
||||
),
|
||||
'messages', (SELECT SUM(sent) AS messages FROM campaigns));
|
||||
|
|
Loading…
Reference in New Issue