diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 312f799..41576c5 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -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) +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 7e777ba..dd46923 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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) diff --git a/cmd/queries.go b/cmd/queries.go index 3fdded4..ed55331 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -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"` diff --git a/cmd/subscribers.go b/cmd/subscribers.go index a4a6288..00aa13f 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -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, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 485d26a..4cae158 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -80,6 +80,10 @@ + + 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 }); diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 0a81ca9..0548817 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -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,28 @@ 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; +} + /* Form fields */ .field { &:not(:last-child) { @@ -368,10 +387,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 +510,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 +535,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 +564,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 +731,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; diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 724a282..efb01a9 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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', diff --git a/frontend/src/utils.js b/frontend/src/utils.js index d102fe2..80816ad 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -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]); diff --git a/frontend/src/views/CampaignAnalytics.vue b/frontend/src/views/CampaignAnalytics.vue new file mode 100644 index 0000000..83e1dc5 --- /dev/null +++ b/frontend/src/views/CampaignAnalytics.vue @@ -0,0 +1,427 @@ + + + {{ $t('analytics.title') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ v.name }} ({{ counts[k] }}) + + + + + + + + + + + + + diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index 65a3641..8d265e1 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -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"> - @@ -70,9 +70,9 @@ - - + {{ l.name }} @@ -103,7 +103,7 @@ - + {{ $t('campaigns.views') }} @@ -140,8 +140,9 @@ - + + changeCampaignStatus(props.row, 'running'))" data-cy="btn-start"> @@ -170,6 +171,25 @@ + + + + + + + changeCampaignStatus(props.row, 'cancelled'))" + data-cy="btn-cancel"> + + + + + + + + @@ -184,14 +204,11 @@ - changeCampaignStatus(props.row, 'cancelled'))" - data-cy="btn-cancel"> - - + + + - + deleteCampaign(props.row))" data-cy="btn-delete"> diff --git a/i18n/cs-cz.json b/i18n/cs-cz.json index 3c53efd..92ee30d 100644 --- a/i18n/cs-cz.json +++ b/i18n/cs-cz.json @@ -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}", diff --git a/i18n/de.json b/i18n/de.json index 8db90ce..9c8e199 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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}", diff --git a/i18n/en.json b/i18n/en.json index aa7005b..7be2ef8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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}", diff --git a/i18n/es.json b/i18n/es.json index 565bb94..ea8496c 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -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}", diff --git a/i18n/fr.json b/i18n/fr.json index 65fb08c..caf9939 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -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}", diff --git a/i18n/it.json b/i18n/it.json index e2d1a8e..19452af 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -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}", diff --git a/i18n/ml.json b/i18n/ml.json index 75a6927..7a47eb4 100644 --- a/i18n/ml.json +++ b/i18n/ml.json @@ -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}", diff --git a/i18n/pl.json b/i18n/pl.json index 7600a2c..4800398 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -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}", diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index f405959..228274a 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -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}", diff --git a/i18n/pt.json b/i18n/pt.json index ea32f36..0faf219 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -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}", diff --git a/i18n/ru.json b/i18n/ru.json index 66bd615..78bba8c 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -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}", diff --git a/i18n/tr.json b/i18n/tr.json index 2fd05e0..2d6c89a 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -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}", diff --git a/queries.sql b/queries.sql index dc60460..e61b7ad 100644 --- a/queries.sql +++ b/queries.sql @@ -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
{{ $t('campaigns.views') }} @@ -140,8 +140,9 @@