Add subscriber status counts to the lists UI.

- Change `query-lists` query to aggregate the subscriber count by
  status (confirmed, unsubscribed etc.) and expose them under a new
  `subscriber_statuses: {}` field in the `GET /lists` API.
- Display the statuses and counts in the lists table on the UI.

Closes #616
This commit is contained in:
Kailash Nadh 2022-02-03 00:03:31 +05:30
parent 182795ec10
commit da30d4688e
4 changed files with 43 additions and 15 deletions

View file

@ -93,6 +93,11 @@ func handleGetLists(c echo.Context) error {
if v.Tags == nil { if v.Tags == nil {
out.Results[i].Tags = make(pq.StringArray, 0) out.Results[i].Tags = make(pq.StringArray, 0)
} }
// Total counts.
for _, c := range v.SubscriberCounts {
out.Results[i].SubscriberCount += c
}
} }
if single { if single {

View file

@ -61,7 +61,7 @@
</b-table-column> </b-table-column>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" <b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')"
header-class="cy-type" sortable> header-class="cy-type" sortable width="15%">
<div class="tags"> <div class="tags">
<b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`"> <b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $t(`lists.types.${props.row.type}`) }} {{ $t(`lists.types.${props.row.type}`) }}
@ -94,6 +94,16 @@
</router-link> </router-link>
</b-table-column> </b-table-column>
<b-table-column v-slot="props" field="subscriber_counts"
header-class="cy-subscribers" width="10%">
<div class="fields stats">
<p v-for="(count, status) in props.row.subscriberStatuses" :key="status">
<label>{{ $t(`subscribers.status.${status}`) }}</label>
<span :class="status">{{ $utils.formatNumber(count) }}</span>
</p>
</div>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')" <b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable> header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }} {{ $utils.niceDate(props.row.createdAt) }}

View file

@ -149,6 +149,9 @@ type subLists struct {
// SubscriberAttribs is the map of key:value attributes of a subscriber. // SubscriberAttribs is the map of key:value attributes of a subscriber.
type SubscriberAttribs map[string]interface{} type SubscriberAttribs map[string]interface{}
// StringIntMap is used to define DB Scan()s.
type StringIntMap map[string]int
// Subscribers represents a slice of Subscriber. // Subscribers represents a slice of Subscriber.
type Subscribers []Subscriber type Subscribers []Subscriber
@ -167,13 +170,14 @@ type SubscriberExport struct {
type List struct { type List struct {
Base Base
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
Optin string `db:"optin" json:"optin"` Optin string `db:"optin" json:"optin"`
Tags pq.StringArray `db:"tags" json:"tags"` Tags pq.StringArray `db:"tags" json:"tags"`
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"` SubscriberCount int `db:"-" json:"subscriber_count"`
SubscriberID int `db:"subscriber_id" json:"-"` SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
SubscriberID int `db:"subscriber_id" json:"-"`
// This is only relevant when querying the lists of a subscriber. // This is only relevant when querying the lists of a subscriber.
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"` SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
@ -319,12 +323,20 @@ func (s SubscriberAttribs) Value() (driver.Value, error) {
return json.Marshal(s) return json.Marshal(s)
} }
// Scan unmarshals JSON into SubscriberAttribs. // Scan unmarshals JSONB from the DB.
func (s SubscriberAttribs) Scan(src interface{}) error { func (s SubscriberAttribs) Scan(src interface{}) error {
if data, ok := src.([]byte); ok { if data, ok := src.([]byte); ok {
return json.Unmarshal(data, &s) return json.Unmarshal(data, &s)
} }
return fmt.Errorf("Could not not decode type %T -> %T", src, s) return fmt.Errorf("could not not decode type %T -> %T", src, s)
}
// Scan unmarshals JSONB from the DB.
func (s StringIntMap) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, &s)
}
return fmt.Errorf("could not not decode type %T -> %T", src, s)
} }
// GetIDs returns the list of campaign IDs. // GetIDs returns the list of campaign IDs.

View file

@ -351,12 +351,13 @@ WITH ls AS (
OFFSET $3 LIMIT (CASE WHEN $4 = 0 THEN NULL ELSE $4 END) OFFSET $3 LIMIT (CASE WHEN $4 = 0 THEN NULL ELSE $4 END)
), ),
counts AS ( counts AS (
SELECT COUNT(*) as subscriber_count, list_id FROM subscriber_lists SELECT list_id, JSON_OBJECT_AGG(status, subscriber_count) AS subscriber_statuses FROM (
WHERE status != 'unsubscribed' SELECT COUNT(*) as subscriber_count, list_id, status FROM subscriber_lists
AND ($1 = 0 OR list_id = $1) WHERE ($1 = 0 OR list_id = $1)
GROUP BY list_id GROUP BY list_id, status
) row GROUP BY list_id
) )
SELECT ls.*, COALESCE(subscriber_count, 0) AS subscriber_count FROM ls SELECT ls.*, subscriber_statuses FROM ls
LEFT JOIN counts ON (counts.list_id = ls.id) ORDER BY %s %s; LEFT JOIN counts ON (counts.list_id = ls.id) ORDER BY %s %s;