Add 'send campaign test' feature

This commit is contained in:
Kailash Nadh 2018-10-29 15:20:49 +05:30
parent 3a1faf0faa
commit d89b22e757
8 changed files with 276 additions and 116 deletions

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -24,6 +25,9 @@ type campaignReq struct {
models.Campaign models.Campaign
MessengerID string `json:"messenger"` MessengerID string `json:"messenger"`
Lists pq.Int64Array `json:"lists"` Lists pq.Int64Array `json:"lists"`
// This is only relevant to campaign test requests.
SubscriberEmails pq.StringArray `json:"subscribers"`
} }
type campaignStats struct { type campaignStats struct {
@ -131,7 +135,8 @@ func handlePreviewCampaign(c echo.Context) error {
} }
tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, body) tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, body)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error compiling template: %v", err))
} }
// Render the message body. // Render the message body.
@ -139,7 +144,8 @@ func handlePreviewCampaign(c echo.Context) error {
if err := tpl.ExecuteTemplate(&out, if err := tpl.ExecuteTemplate(&out,
runner.BaseTPL, runner.BaseTPL,
runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil { runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error executing template: %v", err)) return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error executing template: %v", err))
} }
return c.HTML(http.StatusOK, out.String()) return c.HTML(http.StatusOK, out.String())
@ -408,6 +414,91 @@ func handleGetCampaignMessengers(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()}) return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()})
} }
// handleTestCampaign handles the sending of a campaign message to
// arbitrary subscribers for testing.
func handleTestCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
campID, _ = strconv.Atoi(c.Param("id"))
req campaignReq
)
if campID < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.")
}
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
// Validate.
if err := validateCampaignFields(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if len(req.SubscriberEmails) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
}
// Get the subscribers.
for i := 0; i < len(req.SubscriberEmails); i++ {
req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
}
var subs models.Subscribers
if err := app.Queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err)))
} else if len(subs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.")
}
// The campaign.
var camp models.Campaign
if err := app.Queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
}
// Override certain values in the DB with incoming values.
camp.Name = req.Name
camp.Subject = req.Subject
camp.FromEmail = req.FromEmail
camp.Body = req.Body
// Send the test messages.
for _, s := range subs {
if err := sendTestMessage(&s, &camp, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error sending test: %v", err))
}
}
return c.JSON(http.StatusOK, okResp{true})
}
// sendTestMessage takes a campaign and a subsriber and sends out a sample campain message.
func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) error {
tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, camp.Body)
if err != nil {
return fmt.Errorf("Error compiling template: %v", err)
}
// Render the message body.
var out = bytes.Buffer{}
if err := tpl.ExecuteTemplate(&out,
runner.BaseTPL,
runner.Message{Campaign: camp, Subscriber: sub, UnsubscribeURL: "#dummy"}); err != nil {
return fmt.Errorf("Error executing template: %v", err)
}
if err := app.Messenger.Push(camp.FromEmail, sub.Email, camp.Subject, []byte(out.Bytes())); err != nil {
return err
}
return nil
}
// validateCampaignFields validates incoming campaign field values. // validateCampaignFields validates incoming campaign field values.
func validateCampaignFields(c campaignReq) error { func validateCampaignFields(c campaignReq) error {
if !regexFromAddress.Match([]byte(c.FromEmail)) { if !regexFromAddress.Match([]byte(c.FromEmail)) {
@ -434,6 +525,11 @@ func validateCampaignFields(c campaignReq) error {
} }
} }
_, err := runner.CompileMessageTemplate(tplTag, c.Body)
if err != nil {
return fmt.Errorf("Error compiling campaign body: %v", err)
}
return nil return nil
} }

View File

@ -25,7 +25,7 @@ class Editor extends React.PureComponent {
rawInput: null, rawInput: null,
selContentType: "richtext", selContentType: "richtext",
contentType: "richtext", contentType: "richtext",
body: "", body: ""
} }
quillModules = { quillModules = {
@ -129,7 +129,9 @@ class Editor extends React.PureComponent {
modules={ this.quillModules } modules={ this.quillModules }
defaultValue={ this.props.record.body } defaultValue={ this.props.record.body }
ref={ (o) => { ref={ (o) => {
this.setState({ quill: o }) if(o) {
this.setState({ quill: o })
}
}} }}
onChange={ () => { onChange={ () => {
if(!this.state.quill) { if(!this.state.quill) {
@ -167,7 +169,8 @@ class Editor extends React.PureComponent {
class TheFormDef extends React.PureComponent { class TheFormDef extends React.PureComponent {
state = { state = {
editorVisible: false, editorVisible: false,
sendLater: false sendLater: false,
loading: false
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
@ -200,15 +203,16 @@ class TheFormDef extends React.PureComponent {
if (err) { if (err) {
return return
} }
if(!values.tags) { if(!values.tags) {
values.tags = [] values.tags = []
} }
values.body = this.props.body values.body = this.props.body
values.content_type = this.props.contentType values.content_type = this.props.contentType
// Create a new campaign. // Create a new campaign.
this.setState({ loading: true })
if(!this.props.isSingle) { if(!this.props.isSingle) {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, values).then((resp) => { this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, values).then((resp) => {
notification["success"]({ placement: "topRight", notification["success"]({ placement: "topRight",
@ -218,21 +222,52 @@ class TheFormDef extends React.PureComponent {
this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)) this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id))
this.props.fetchRecord(resp.data.data.id) this.props.fetchRecord(resp.data.data.id)
this.props.setCurrentTab("content") this.props.setCurrentTab("content")
this.setState({ loading: false })
}).catch(e => { }).catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} else { } else {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaign, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => { this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaign, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => {
notification["success"]({ placement: "topRight", notification["success"]({ placement: "topRight",
message: "Campaign updated", message: "Campaign updated",
description: `"${values["name"]}" updated` }) description: `"${values["name"]}" updated` })
}).catch(e => { }).catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
this.setState({ loading: false })
}) })
} }
}) })
} }
handleTestCampaign = (e) => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
if(!values.tags) {
values.tags = []
}
values.id = this.props.record.id
values.body = this.props.body
values.content_type = this.props.contentType
this.setState({ loading: true })
this.props.request(cs.Routes.TestCampaign, cs.MethodPost, values).then((resp) => {
this.setState({ loading: false })
notification["success"]({ placement: "topRight",
message: "Test sent",
description: `Test messages sent` })
}).catch(e => {
this.setState({ loading: false })
notification["error"]({ message: "Error", description: e.message })
})
})
}
render() { render() {
const { record } = this.props; const { record } = this.props;
const { getFieldDecorator } = this.props.form const { getFieldDecorator } = this.props.form
@ -244,101 +279,107 @@ class TheFormDef extends React.PureComponent {
return ( return (
<div> <div>
<Form onSubmit={ this.handleSubmit }> <Spin spinning={ this.state.loading }>
<Form.Item {...formItemLayout} label="Campaign name"> <Form onSubmit={ this.handleSubmit }>
{getFieldDecorator("name", { <Form.Item {...formItemLayout} label="Campaign name">
extra: "This is internal and will not be visible to subscribers", {getFieldDecorator("name", {
initialValue: record.name, extra: "This is internal and will not be visible to subscribers",
rules: [{ required: true }] initialValue: record.name,
})(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)} rules: [{ required: true }]
</Form.Item> })(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)}
<Form.Item {...formItemLayout} label="Subject"> </Form.Item>
{getFieldDecorator("subject", { <Form.Item {...formItemLayout} label="Subject">
initialValue: record.subject, {getFieldDecorator("subject", {
rules: [{ required: true }] initialValue: record.subject,
})(<Input disabled={ this.props.formDisabled } maxLength="500" />)} rules: [{ required: true }]
</Form.Item> })(<Input disabled={ this.props.formDisabled } maxLength="500" />)}
<Form.Item {...formItemLayout} label="From address"> </Form.Item>
{getFieldDecorator("from_email", { <Form.Item {...formItemLayout} label="From address">
initialValue: record.from_email, {getFieldDecorator("from_email", {
rules: [{ required: true }, { validator: this.validateEmail }] initialValue: record.from_email,
})(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)} rules: [{ required: true }, { validator: this.validateEmail }]
</Form.Item> })(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)}
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to"> </Form.Item>
{getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })( <Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to">
<Select disabled={ this.props.formDisabled } mode="multiple"> {getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })(
{[...this.props.data[cs.ModelLists]].map((v, i) => <Select disabled={ this.props.formDisabled } mode="multiple">
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option> {[...this.props.data[cs.ModelLists]].map((v, i) =>
)} <Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
</Select> )}
)} </Select>
</Form.Item> )}
<Form.Item {...formItemLayout} label="Template" extra="Template"> </Form.Item>
{getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })( <Form.Item {...formItemLayout} label="Template" extra="Template">
<Select disabled={ this.props.formDisabled }> {getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })(
{[...this.props.data[cs.ModelTemplates]].map((v, i) => <Select disabled={ this.props.formDisabled }>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option> {[...this.props.data[cs.ModelTemplates]].map((v, i) =>
)} <Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
</Select> )}
)} </Select>
</Form.Item> )}
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags"> </Form.Item>
{getFieldDecorator("tags", { initialValue: record.tags })( <Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags">
<Select disabled={ this.props.formDisabled } mode="tags"></Select> {getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={ this.props.formDisabled } mode="tags"></Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Messenger">
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })(
<Radio.Group className="messengers">
{[...this.props.messengers].map((v, i) =>
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
)}
</Radio.Group>
)} )}
</Form.Item>
<Form.Item {...formItemLayout} label="Messenger">
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })(
<Radio.Group className="messengers">
{[...this.props.messengers].map((v, i) =>
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
)}
</Radio.Group>
)}
</Form.Item>
<hr />
<Form.Item {...formItemLayout} label="Send later?">
<Row>
<Col span={ 2 }>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })(
<Switch disabled={ this.props.formDisabled }
checked={ this.state.sendLater }
onChange={ this.handleSendLater } />
)}
</Col>
<Col span={ 12 }>
{this.state.sendLater && getFieldDecorator("send_at",
{ initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })(
<DatePicker
disabled={ this.props.formDisabled }
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item>
{ !this.props.formDisabled &&
<Form.Item {...formItemTailLayout}>
<Button htmlType="submit" type="primary">
<Icon type="save" /> { !this.props.isSingle ? "Continue" : "Save changes" }
</Button>
</Form.Item> </Form.Item>
}
</Form>
{ this.props.isSingle &&
<div>
<hr /> <hr />
<Form.Item {...formItemLayout} label="Send test e-mails" extra="Hit Enter after typing an e-mail to add multiple emails"> <Form.Item {...formItemLayout} label="Send later?">
<Select mode="tags" style={{ minWidth: 320 }}></Select> <Row>
<div><Button htmlType="submit"><Icon type="mail" /> Send test</Button></div> <Col span={ 2 }>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })(
<Switch disabled={ this.props.formDisabled }
checked={ this.state.sendLater }
onChange={ this.handleSendLater } />
)}
</Col>
<Col span={ 12 }>
{this.state.sendLater && getFieldDecorator("send_at",
{ initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })(
<DatePicker
disabled={ this.props.formDisabled }
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item> </Form.Item>
</div>
} { !this.props.formDisabled &&
<Form.Item {...formItemTailLayout}>
<Button htmlType="submit" type="primary">
<Icon type="save" /> { !this.props.isSingle ? "Continue" : "Save changes" }
</Button>
</Form.Item>
}
{ this.props.isSingle &&
<div>
<hr />
<Form.Item {...formItemLayout} label="Send test messages" extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers.">
{getFieldDecorator("subscribers")(
<Select mode="tags" style={{ width: "100%" }}></Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="&nbsp;" colon={ false }>
<Button onClick={ this.handleTestCampaign }><Icon type="mail" /> Send test</Button>
</Form.Item>
</div>
}
</Form>
</Spin>
</div> </div>
) )
@ -381,7 +422,7 @@ class Campaign extends React.PureComponent {
} }
fetchRecord = (id) => { fetchRecord = (id) => {
this.props.request(cs.Routes.GetCampaigns, cs.MethodGet, { id: id }).then((r) => { this.props.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id }).then((r) => {
const record = r.data.data const record = r.data.data
this.setState({ record: record, loading: false }) this.setState({ record: record, loading: false })
@ -440,7 +481,7 @@ class Campaign extends React.PureComponent {
record={ this.state.record } record={ this.state.record }
isSingle={ this.state.record.id ? true : false } isSingle={ this.state.record.id ? true : false }
messengers={ this.state.messengers } messengers={ this.state.messengers }
body={ this.state.body } body={ this.state.body ? this.state.body : this.state.record.body }
contentType={ this.state.contentType } contentType={ this.state.contentType }
formDisabled={ this.state.formDisabled } formDisabled={ this.state.formDisabled }
fetchRecord={ this.fetchRecord } fetchRecord={ this.fetchRecord }
@ -476,8 +517,7 @@ class Campaign extends React.PureComponent {
<Media { ...{ ...this.props, <Media { ...{ ...this.props,
insertMedia: this.state.editor ? this.state.editor.insertMedia : null, insertMedia: this.state.editor ? this.state.editor.insertMedia : null,
onCancel: this.toggleMedia, onCancel: this.toggleMedia,
onOk: this.toggleMedia onOk: this.toggleMedia }} />
}} />
</Modal> </Modal>
{ this.state.previewRecord && { this.state.previewRecord &&

View File

@ -70,8 +70,10 @@ export const Routes = {
ViewCampaign: "/campaigns/:id", ViewCampaign: "/campaigns/:id",
GetCampaignMessengers: "/api/campaigns/messengers", GetCampaignMessengers: "/api/campaigns/messengers",
GetCampaigns: "/api/campaigns", GetCampaigns: "/api/campaigns",
GetCampaign: "/api/campaigns/:id",
GetRunningCampaignStats: "/api/campaigns/running/stats", GetRunningCampaignStats: "/api/campaigns/running/stats",
CreateCampaign: "/api/campaigns", CreateCampaign: "/api/campaigns",
TestCampaign: "/api/campaigns/:id/test",
UpdateCampaign: "/api/campaigns/:id", UpdateCampaign: "/api/campaigns/:id",
UpdateCampaignStatus: "/api/campaigns/:id/status", UpdateCampaignStatus: "/api/campaigns/:id/status",
DeleteCampaign: "/api/campaigns/:id", DeleteCampaign: "/api/campaigns/:id",

13
main.go
View File

@ -37,6 +37,8 @@ type App struct {
Importer *subimporter.Importer Importer *subimporter.Importer
Runner *runner.Runner Runner *runner.Runner
Logger *log.Logger Logger *log.Logger
Messenger messenger.Messenger
} }
func init() { func init() {
@ -104,6 +106,7 @@ func registerHandlers(e *echo.Echo) {
e.GET("/api/campaigns/:id", handleGetCampaigns) e.GET("/api/campaigns/:id", handleGetCampaigns)
e.GET("/api/campaigns/:id/preview", handlePreviewCampaign) e.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
e.POST("/api/campaigns/:id/preview", handlePreviewCampaign) e.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
e.POST("/api/campaigns/:id/test", handleTestCampaign)
e.POST("/api/campaigns", handleCreateCampaign) e.POST("/api/campaigns", handleCreateCampaign)
e.PUT("/api/campaigns/:id", handleUpdateCampaign) e.PUT("/api/campaigns/:id", handleUpdateCampaign)
e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
@ -139,7 +142,7 @@ func registerHandlers(e *echo.Echo) {
} }
// initMessengers initializes various messaging backends. // initMessengers initializes various messaging backends.
func initMessengers(r *runner.Runner) { func initMessengers(r *runner.Runner) messenger.Messenger {
// Load SMTP configurations for the default e-mail Messenger. // Load SMTP configurations for the default e-mail Messenger.
var srv []messenger.Server var srv []messenger.Server
for name := range viper.GetStringMapString("smtp") { for name := range viper.GetStringMapString("smtp") {
@ -158,14 +161,16 @@ func initMessengers(r *runner.Runner) {
logger.Printf("loaded SMTP config %s (%s@%s)", s.Name, s.Username, s.Host) logger.Printf("loaded SMTP config %s (%s@%s)", s.Name, s.Username, s.Host)
} }
e, err := messenger.NewEmailer(srv...) msgr, err := messenger.NewEmailer(srv...)
if err != nil { if err != nil {
logger.Fatalf("error loading e-mail messenger: %v", err) logger.Fatalf("error loading e-mail messenger: %v", err)
} }
if err := r.AddMessenger(e); err != nil { if err := r.AddMessenger(msgr); err != nil {
logger.Printf("error registering messenger %s", err) logger.Printf("error registering messenger %s", err)
} }
return msgr
} }
func main() { func main() {
@ -222,7 +227,7 @@ func main() {
app.Runner = r app.Runner = r
// Add messengers. // Add messengers.
initMessengers(app.Runner) app.Messenger = initMessengers(app.Runner)
go r.Run(time.Duration(time.Second * 2)) go r.Run(time.Duration(time.Second * 2))
r.SpawnWorkers() r.SpawnWorkers()

View File

@ -10,6 +10,7 @@ import (
type Queries struct { type Queries struct {
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"` UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
GetSubscriber *sqlx.Stmt `query:"get-subscriber"` GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
QuerySubscribers string `query:"query-subscribers"` QuerySubscribers string `query:"query-subscribers"`
QuerySubscribersCount string `query:"query-subscribers-count"` QuerySubscribersCount string `query:"query-subscribers-count"`

View File

@ -3,6 +3,11 @@
-- Get a single subscriber by id or UUID. -- Get a single subscriber by id or UUID.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- subscribers
-- name: get-subscribers-by-emails
-- Get subscribers by emails.
SELECT * FROM subscribers WHERE email=ANY($1);
-- name: get-subscriber-lists -- name: get-subscriber-lists
-- Get lists belonging to subscribers. -- Get lists belonging to subscribers.
SELECT lists.*, subscriber_lists.subscriber_id, subscriber_lists.status AS subscription_status FROM lists SELECT lists.*, subscriber_lists.subscriber_id, subscriber_lists.status AS subscription_status FROM lists
@ -158,7 +163,14 @@ WHERE ($1 = 0 OR id = $1) AND status=(CASE WHEN $2 != '' THEN $2::campaign_statu
ORDER BY created_at DESC OFFSET $3 LIMIT $4; ORDER BY created_at DESC OFFSET $3 LIMIT $4;
-- name: get-campaign-for-preview -- name: get-campaign-for-preview
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
) l
) AS lists
FROM campaigns FROM campaigns
LEFT JOIN templates ON (templates.id = campaigns.template_id) LEFT JOIN templates ON (templates.id = campaigns.template_id)
WHERE campaigns.id = $1; WHERE campaigns.id = $1;

View File

@ -43,10 +43,10 @@ var jsonMap = []byte("{}")
// handleGetSubscriber handles the retrieval of a single subscriber by ID. // handleGetSubscriber handles the retrieval of a single subscriber by ID.
func handleGetSubscriber(c echo.Context) error { func handleGetSubscriber(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
out models.Subscribers
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
out models.Subscribers
) )
if id < 1 { if id < 1 {
@ -183,6 +183,10 @@ func handleCreateSubscriber(c echo.Context) error {
true, true,
req.Lists) req.Lists)
if err != nil { if err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
}
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err)) fmt.Sprintf("Error creating subscriber: %v", err))
} }

View File

@ -20,11 +20,11 @@ const (
tplTag = `{{ template "content" . }}` tplTag = `{{ template "content" . }}`
dummyTpl = ` dummyTpl = `
<p>Hi there</p> <p>Hi there</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.</p> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.</p>
<h3>Sub heading</h3> <h3>Sub heading</h3>
<p>Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.</p> <p>Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.</p>
<p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>` <p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>`
) )