Skip to content

Commit

Permalink
WIP: Add support for publishing campaigns to publish archives.
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Nov 10, 2022
1 parent 74322cd commit 9add728
Show file tree
Hide file tree
Showing 41 changed files with 697 additions and 49 deletions.
172 changes: 172 additions & 0 deletions cmd/archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"bytes"
"encoding/json"
"net/http"

"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
null "gopkg.in/volatiletech/null.v6"
)

type campArchive struct {
UUID string `json:"uuid"`
Subject string `json:"subject"`
CreatedAt null.Time `json:"created_at"`
URL string `json:"url"`
}

// handleGetCampaignArchives renders the public campaign archives page.
func handleGetCampaignArchives(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 50)
)

camps, total, err := getCampaignArchives(pg.Offset, pg.Limit, app)
if err != nil {
return err
}

var out models.PageResults
if len(camps) == 0 {
out.Results = []campArchive{}
return c.JSON(http.StatusOK, okResp{out})
}

// Meta.
out.Results = camps
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage

return c.JSON(200, okResp{out})
}

// handleCampaignArchivesPage renders the public campaign archives page.
func handleCampaignArchivesPage(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 50)
)

out, total, err := getCampaignArchives(pg.Offset, pg.Limit, app)
if err != nil {
return err
}

title := app.i18n.T("public.archiveTitle")
return c.Render(http.StatusOK, "archive", struct {
Title string
Description string
Campaigns []campArchive
Total int
Page int
PerPage int
}{title, title, out, total, pg.Page, pg.PerPage})
}

// handleCampaignArchivePage renders the public campaign archives page.
func handleCampaignArchivePage(c echo.Context) error {
var (
app = c.Get("app").(*App)
uuid = c.Param("uuid")
)

pubCamp, err := app.core.GetArchivedCampaign(0, uuid)
if err != nil {
if er, ok := err.(*echo.HTTPError); ok {
if er.Code == http.StatusBadRequest {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
}
}

return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}

out, err := compileArchiveCampaigns([]models.Campaign{pubCamp}, app)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}

// Render the message body.
camp := out[0].Campaign
msg, err := app.manager.NewCampaignMessage(camp, out[0].Subscriber)
if err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}

return c.HTML(http.StatusOK, string(msg.Body()))
}

func getCampaignArchives(offset, limit int, app *App) ([]campArchive, int, error) {
pubCamps, total, err := app.core.GetArchivedCampaigns(offset, limit)
if err != nil {
return []campArchive{}, total, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}

msgs, err := compileArchiveCampaigns(pubCamps, app)
if err != nil {
return []campArchive{}, total, err
}

out := make([]campArchive, 0, len(msgs))
for _, m := range msgs {
camp := m.Campaign
out = append(out, campArchive{
UUID: camp.UUID,
Subject: camp.Subject,
CreatedAt: camp.CreatedAt,
URL: app.constants.ArchiveURL + "/" + camp.UUID,
})
}

return out, total, nil
}

func compileArchiveCampaigns(camps []models.Campaign, app *App) ([]manager.CampaignMessage, error) {
var (
b = bytes.Buffer{}
)

out := make([]manager.CampaignMessage, 0, len(camps))
for _, camp := range camps {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}

// Load the dummy subscriber meta.
var sub models.Subscriber
if err := json.Unmarshal([]byte(camp.ArchiveMeta), &sub); err != nil {
app.log.Printf("error unmarshalling campaign archive meta: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}

m := manager.CampaignMessage{
Campaign: &camp,
Subscriber: sub,
}

// Render the subject if it's a template.
if camp.SubjectTpl != nil {
if err := camp.SubjectTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
return nil, err
}
camp.Subject = b.String()
b.Reset()

}

out = append(out, m)
}

return out, nil
}
34 changes: 34 additions & 0 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
Expand Down Expand Up @@ -215,6 +216,10 @@ func handleCreateCampaign(c echo.Context) error {
o = c
}

if o.ArchiveTemplateID == 0 {
o.ArchiveTemplateID = o.TemplateID
}

out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs)
if err != nil {
return err
Expand Down Expand Up @@ -294,6 +299,31 @@ func handleUpdateCampaignStatus(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}

// handleUpdateCampaignArchive handles campaign status modification.
func handleUpdateCampaignArchive(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)

req := struct {
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
}{}

// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}

if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta); err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{true})
}

// handleDeleteCampaign handles campaign deletion.
// Only scheduled campaigns that have not started yet can be deleted.
func handleDeleteCampaign(c echo.Context) error {
Expand Down Expand Up @@ -529,6 +559,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
c.Headers = make([]map[string]string, 0)
}

if len(c.ArchiveMeta) == 0 {
c.ArchiveMeta = json.RawMessage("{}")
}

return c, nil
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
g.POST("/api/campaigns", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)

g.GET("/api/media", handleGetMedia)
Expand Down Expand Up @@ -164,6 +165,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
// Public API endpoints.
e.GET("/api/public/lists", handleGetPublicLists)
e.POST("/api/public/subscription", handlePublicSubscription)
e.GET("/api/public/archive", handleGetCampaignArchives)

// /public/static/* file server is registered in initHTTPServer().
// Public subscriber facing views.
Expand All @@ -185,6 +187,8 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
"campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID")))
e.GET("/archive", handleCampaignArchivesPage)
e.GET("/archive/:uuid", handleCampaignArchivePage)

e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))
Expand Down
5 changes: 5 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type constants struct {
ViewTrackURL string
OptinURL string
MessageURL string
ArchiveURL string
MediaProvider string

BounceWebhooksEnabled bool
Expand Down Expand Up @@ -370,6 +371,9 @@ func initConstants() *constants {
// url.com/link/{campaign_uuid}/{subscriber_uuid}
c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL)

// url.com/archive
c.ArchiveURL = c.RootURL + "/archive"

// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)

Expand Down Expand Up @@ -424,6 +428,7 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma
LinkTrackURL: cs.LinkTrackURL,
ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL,
ArchiveURL: cs.ArchiveURL,
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
SlidingWindow: ko.Bool("app.message_sliding_window"),
SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
Expand Down
3 changes: 3 additions & 0 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
emailMsgr,
campTplID,
pq.Int64Array{1},
false,
campTplID,
"{}",
); err != nil {
lo.Fatalf("error creating sample campaign: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/manager_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, erro
// GetCampaign fetches a campaign from the database.
func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) {
var out = &models.Campaign{}
err := r.queries.GetCampaign.Get(out, campID, nil)
err := r.queries.GetCampaign.Get(out, campID, nil, "default")
return out, err
}

Expand Down
1 change: 0 additions & 1 deletion cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ func handleViewCampaignMessage(c echo.Context) error {
}
}

app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
Expand Down
52 changes: 52 additions & 0 deletions frontend/cypress/e2e/archive.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const apiUrl = Cypress.env('apiUrl');

describe('Archive', () => {
it('Opens campaigns page', () => {
cy.resetDB();
cy.loginAndVisit('/campaigns');
cy.wait(500);
});

it('Clones campaign', () => {
cy.loginAndVisit('/campaigns');
cy.get('[data-cy=btn-clone]').first().click();
cy.get('.modal input').clear().type('clone').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.clickMenu('all-campaigns');
});

it('Starts un-archived campaign', () => {
cy.get('td[data-label=Status] a').eq(0).click();
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
});

it('Enables archive on one campaign', () => {
cy.loginAndVisit('/campaigns');
cy.wait(250);
cy.get('td[data-label=Status] a').eq(1).click();

// Switch to archive tab and enable archive.
cy.get('.b-tabs nav a').eq(2).click();
cy.wait(500);
cy.get('[data-cy=btn-archive] .check').click();
cy.get('[data-cy=archive-meta]').clear()
.type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { 'parseSpecialCharSequences': false });

// Start the campaign.
cy.get('[data-cy=btn-save]').click();
cy.wait(500);
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
});

it('Opens campaign archive page', () => {
cy.loginAndVisit(`${apiUrl}/archive`);
cy.get('li a').click();
cy.get('h3').contains('Hi Archive!');
cy.get('p').eq(0).contains('Bengaluru');
});
});
3 changes: 3 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ export const updateCampaign = async (id, data) => http.put(`/api/campaigns/${id}
export const changeCampaignStatus = async (id, status) => http.put(`/api/campaigns/${id}/status`,
{ status }, { loading: models.campaigns });

export const updateCampaignArchive = async (id, data) => http.put(`/api/campaigns/${id}/archive`, data,
{ loading: models.campaigns });

export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
{ loading: models.campaigns });

Expand Down
Loading

0 comments on commit 9add728

Please sign in to comment.