Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,11 @@ func (a *App) GetCampaignViewAnalytics(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}

// ExportCampaignResultsCSV returns campaign results as a CSV file.
func (a *App) ExportCampaignResultsCSV(c echo.Context) error {
return a.core.ExportCampaignResultsCSV(c.Response())
}

// sendTestMessage takes a campaign and a subscriber and sends out a sample campaign message.
func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campaign) error {
if err := camp.CompileTemplate(a.manager.TemplateFuncs(camp)); err != nil {
Expand Down
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {

g.GET("/api/campaigns", pm(a.GetCampaigns, "campaigns:get_all", "campaigns:get"))
g.GET("/api/campaigns/running/stats", pm(a.GetRunningCampaignStats, "campaigns:get_all", "campaigns:get"))
g.GET("/api/campaigns/results/csv", pm(a.ExportCampaignResultsCSV, "campaigns:get_analytics"))
g.GET("/api/campaigns/:id", pm(hasID(a.GetCampaign), "campaigns:get_all", "campaigns:get"))
g.GET("/api/campaigns/analytics/:type", pm(a.GetCampaignViewAnalytics, "campaigns:get_analytics"))
g.GET("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
Expand Down
4 changes: 4 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,10 @@ func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.
Tags: map[string]string{"name": "get-campaign-click-counts"},
}
qMap["get-campaign-link-counts"].Query = fmt.Sprintf(qMap["get-campaign-link-counts"].Query, linkSel)
qMap["fetch-campaign-results"] = &goyesql.Query{
Query: `SELECT * FROM campaign_results_view`,
Tags: map[string]string{"name": "fetch-campaign-results"},
}

// Scan and prepare all queries.
var q models.Queries
Expand Down
30 changes: 22 additions & 8 deletions frontend/src/views/CampaignAnalytics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,23 @@
</div><!-- columns -->
</form>

<p class="is-size-7 mt-2 has-text-grey-light">
<template v-if="settings['privacy.individual_tracking']">
{{ $t('analytics.isUnique') }}
</template>
<template v-else>
{{ $t('analytics.nonUnique') }}
</template>
</p>
<div class="level">
<div class="level-left">
<p class="is-size-7 mt-2 has-text-grey-light">
<template v-if="settings['privacy.individual_tracking']">
{{ $t('analytics.isUnique') }}
</template>
<template v-else>
{{ $t('analytics.nonUnique') }}
</template>
</p>
</div>
<div class="level-right">
<b-button type="is-primary" icon-left="download" @click="exportResults" size="is-small">
{{ $t('analytics.export') }}
</b-button>
</div>
</div>

<section class="charts mt-5">
<div class="chart" v-for="(v, k) in charts" :key="k">
Expand Down Expand Up @@ -288,6 +297,11 @@ export default Vue.extend({
window.open(this.urls[bars[0].index], '_blank', 'noopener noreferrer');
}
},

exportResults() {
// Open the CSV export URL in a new window
window.open('/api/campaigns/results/csv', '_blank');
},
},

computed: {
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"_.name": "English (en)",
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
"analytics.count": "Count",
"analytics.export": "Export Results",
"analytics.fromDate": "From",
"analytics.invalidDates": "Invalid `from` or `to` dates.",
"analytics.isUnique": "The counts are unique per subscriber.",
Expand Down
45 changes: 45 additions & 0 deletions internal/core/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"net/http"
"time"

"encoding/csv"
"fmt"

"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/models"
Expand Down Expand Up @@ -448,3 +451,45 @@ func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {

return nil
}

// FetchCampaignResults fetches campaign results from the campaign_results_view.
func (c *Core) FetchCampaignResults() ([]models.CampaignResult, error) {
var results []models.CampaignResult
if err := c.q.FetchCampaignResults.Select(&results); err != nil {
c.log.Printf("error fetching campaign results: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaignResults}", "error", pqErrMsg(err)))
}
return results, nil
}

// ExportCampaignResultsCSV exports campaign results as a CSV file.
func (c *Core) ExportCampaignResultsCSV(w http.ResponseWriter) error {
results, err := c.FetchCampaignResults()
if err != nil {
return err
}

w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=campaign_results.csv")

writer := csv.NewWriter(w)
defer writer.Flush()

// Write CSV header.
writer.Write([]string{"Campaign Name", "Campaign ID", "Email", "Link ID", "Link URL", "Count"})

// Write data rows.
for _, r := range results {
writer.Write([]string{
r.CampaignName,
fmt.Sprintf("%d", r.CampaignID),
r.Email,
fmt.Sprintf("%d", r.LinkID),
r.LinkURL,
fmt.Sprintf("%d", r.Count),
})

}

return nil
}
10 changes: 10 additions & 0 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,16 @@ type Attachment struct {
Content []byte
}

type CampaignResult struct {
CampaignName string `db:"campaign_name" json:"campaign_name"`
CampaignID int `db:"campaign_id" json:"campaign_id"`
Email string `db:"email" json:"email"`
LinkID int `db:"link_id" json:"link_id"`
LinkURL string `db:"link_url" json:"link_url"`
Count int `db:"count" json:"count"`
}


// TxMessage represents an e-mail campaign.
type TxMessage struct {
SubscriberEmails []string `json:"subscriber_emails"`
Expand Down
3 changes: 3 additions & 0 deletions models/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ type Queries struct {
DeleteRole *sqlx.Stmt `query:"delete-role"`
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`

// FetchCampaignResults retrieves campaign results from the campaign_results_view.
FetchCampaignResults *sqlx.Stmt
}

// compileSubscriberQueryTpl takes an arbitrary WHERE expressions
Expand Down
16 changes: 16 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,19 @@ CREATE MATERIALIZED VIEW mat_list_subscriber_stats AS
UNION ALL
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(id) AS subscriber_count FROM subscribers;
DROP INDEX IF EXISTS mat_list_subscriber_stats_idx; CREATE UNIQUE INDEX mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);

-- Create a view to fetch campaign results
CREATE VIEW campaign_results_view AS
SELECT
c.name AS campaign_name,
c.id AS campaign_id,
s.email AS email,
ll.id AS link_id,
ll.url AS link_url,
COUNT(s.email) AS count
FROM link_clicks l
LEFT JOIN links ll ON ll.id = l.link_id
LEFT JOIN subscribers s ON l.subscriber_id = s.id
LEFT JOIN campaigns c ON l.campaign_id = c.id
WHERE s.email IS NOT NULL
GROUP BY c.name, c.id, s.email, ll.id, ll.url;