Add support for sending 'opt-in' campaigns.
- Campaigns now have a `type` property (regular, opt-in) - Opt-in campaigns work for double opt-in lists and e-mail subscribers who haven't confirmed their subscriptions. - Lists UI shows a 'Send opt-in campaign' optin that automatically creates an opt-in campaign for the list with a default message body that can be tweaked before sending the campaign. - Primary usecase is to send opt-in campaigns to subscribers who are added via bulk import. This is a breaking change. Adds a new Postgres enum type `campaign_type` and a new column `type` to the campaigns table.
This commit is contained in:
parent
9a890c77ab
commit
022b35c4a7
11 changed files with 185 additions and 31 deletions
98
campaigns.go
98
campaigns.go
|
@ -1,10 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -33,6 +36,8 @@ type campaignReq struct {
|
||||||
|
|
||||||
// This is only relevant to campaign test requests.
|
// This is only relevant to campaign test requests.
|
||||||
SubscriberEmails pq.StringArray `json:"subscribers"`
|
SubscriberEmails pq.StringArray `json:"subscribers"`
|
||||||
|
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type campaignStats struct {
|
type campaignStats struct {
|
||||||
|
@ -191,9 +196,20 @@ func handleCreateCampaign(c echo.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the campaign's 'opt-in', prepare a default message.
|
||||||
|
if o.Type == models.CampaignTypeOptin {
|
||||||
|
op, err := makeOptinCampaignMessage(o, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o = op
|
||||||
|
}
|
||||||
|
|
||||||
// Validate.
|
// Validate.
|
||||||
if err := validateCampaignFields(o, app); err != nil {
|
if c, err := validateCampaignFields(o, app); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
o = c
|
||||||
}
|
}
|
||||||
|
|
||||||
if !app.Manager.HasMessenger(o.MessengerID) {
|
if !app.Manager.HasMessenger(o.MessengerID) {
|
||||||
|
@ -205,6 +221,7 @@ func handleCreateCampaign(c echo.Context) error {
|
||||||
var newID int
|
var newID int
|
||||||
if err := app.Queries.CreateCampaign.Get(&newID,
|
if err := app.Queries.CreateCampaign.Get(&newID,
|
||||||
uuid.NewV4(),
|
uuid.NewV4(),
|
||||||
|
o.Type,
|
||||||
o.Name,
|
o.Name,
|
||||||
o.Subject,
|
o.Subject,
|
||||||
o.FromEmail,
|
o.FromEmail,
|
||||||
|
@ -228,7 +245,6 @@ func handleCreateCampaign(c echo.Context) error {
|
||||||
// Hand over to the GET handler to return the last insertion.
|
// Hand over to the GET handler to return the last insertion.
|
||||||
c.SetParamNames("id")
|
c.SetParamNames("id")
|
||||||
c.SetParamValues(fmt.Sprintf("%d", newID))
|
c.SetParamValues(fmt.Sprintf("%d", newID))
|
||||||
|
|
||||||
return handleGetCampaigns(c)
|
return handleGetCampaigns(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,8 +281,10 @@ func handleUpdateCampaign(c echo.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateCampaignFields(o, app); err != nil {
|
if c, err := validateCampaignFields(o, app); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
o = c
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
|
res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
|
||||||
|
@ -457,8 +475,10 @@ func handleTestCampaign(c echo.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Validate.
|
// Validate.
|
||||||
if err := validateCampaignFields(req, app); err != nil {
|
if c, err := validateCampaignFields(req, app); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
req = c
|
||||||
}
|
}
|
||||||
if len(req.SubscriberEmails) == 0 {
|
if len(req.SubscriberEmails) == 0 {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
|
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
|
||||||
|
@ -524,37 +544,39 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCampaignFields validates incoming campaign field values.
|
// validateCampaignFields validates incoming campaign field values.
|
||||||
func validateCampaignFields(c campaignReq, app *App) error {
|
func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
||||||
if !regexFromAddress.Match([]byte(c.FromEmail)) {
|
if c.FromEmail == "" {
|
||||||
|
c.FromEmail = app.Constants.FromEmail
|
||||||
|
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
|
||||||
if !govalidator.IsEmail(c.FromEmail) {
|
if !govalidator.IsEmail(c.FromEmail) {
|
||||||
return errors.New("invalid `from_email`")
|
return c, errors.New("invalid `from_email`")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
|
if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
|
||||||
return errors.New("invalid length for `name`")
|
return c, errors.New("invalid length for `name`")
|
||||||
}
|
}
|
||||||
if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
|
if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
|
||||||
return errors.New("invalid length for `subject`")
|
return c, errors.New("invalid length for `subject`")
|
||||||
}
|
}
|
||||||
|
|
||||||
// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
|
// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
|
||||||
// return errors.New("invalid length for `body`")
|
// return c,errors.New("invalid length for `body`")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// If there's a "send_at" date, it should be in the future.
|
// If there's a "send_at" date, it should be in the future.
|
||||||
if c.SendAt.Valid {
|
if c.SendAt.Valid {
|
||||||
if c.SendAt.Time.Before(time.Now()) {
|
if c.SendAt.Time.Before(time.Now()) {
|
||||||
return errors.New("`send_at` date should be in the future")
|
return c, errors.New("`send_at` date should be in the future")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
|
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
|
||||||
if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil {
|
if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil {
|
||||||
return fmt.Errorf("Error compiling campaign body: %v", err)
|
return c, fmt.Errorf("Error compiling campaign body: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCampaignalMutable tells if a campaign's in a state where it's
|
// isCampaignalMutable tells if a campaign's in a state where it's
|
||||||
|
@ -564,3 +586,53 @@ func isCampaignalMutable(status string) bool {
|
||||||
status == models.CampaignStatusCancelled ||
|
status == models.CampaignStatusCancelled ||
|
||||||
status == models.CampaignStatusFinished
|
status == models.CampaignStatusFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeOptinCampaignMessage makes a default opt-in campaign message body.
|
||||||
|
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
|
||||||
|
if len(o.ListIDs) == 0 {
|
||||||
|
return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch double opt-in lists from the given list IDs.
|
||||||
|
var lists []models.List
|
||||||
|
err := app.Queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
||||||
|
return o, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
"Error fetching optin lists.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No opt-in lists.
|
||||||
|
if len(lists) == 0 {
|
||||||
|
return o, echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
"No opt-in lists found to create campaign.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the opt-in URL with list IDs.
|
||||||
|
var (
|
||||||
|
listIDs = url.Values{}
|
||||||
|
listNames = make([]string, 0, len(lists))
|
||||||
|
)
|
||||||
|
for _, l := range lists {
|
||||||
|
listIDs.Add("l", l.UUID)
|
||||||
|
listNames = append(listNames, l.Name)
|
||||||
|
}
|
||||||
|
// optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
|
||||||
|
optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))
|
||||||
|
|
||||||
|
// Prepare sample opt-in message for the campaign.
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := app.NotifTpls.ExecuteTemplate(&b, "optin-campaign", struct {
|
||||||
|
Lists []models.List
|
||||||
|
OptinURLAttr template.HTMLAttr
|
||||||
|
}{lists, optinURLAttr}); err != nil {
|
||||||
|
app.Logger.Printf("error compiling 'optin-campaign' template: %v", err)
|
||||||
|
return o, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
"Error compiling opt-in campaign template.")
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Name = "Opt-in campaign " + strings.Join(listNames, ", ")
|
||||||
|
o.Subject = "Confirm your subscription(s)"
|
||||||
|
o.Body = b.String()
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
17
email-templates/subscriber-optin-campaign.html
Normal file
17
email-templates/subscriber-optin-campaign.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{{ define "optin-campaign" }}
|
||||||
|
|
||||||
|
<p>Hi {{`{{ .Subscriber.FirstName }}`}},</p>
|
||||||
|
<p>You have been added to the following mailing lists:</p>
|
||||||
|
<ul>
|
||||||
|
{{ range $i, $l := .Lists }}
|
||||||
|
{{ if eq .Type "public" }}
|
||||||
|
<li>{{ .Name }}</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>Private list</li>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<a class="button" {{ .OptinURLAttr }} class="button">Confirm subscription(s)</a>
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
|
@ -259,6 +259,7 @@ class TheFormDef extends React.PureComponent {
|
||||||
values.tags = []
|
values.tags = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
values.type = cs.CampaignTypeRegular
|
||||||
values.body = this.props.body
|
values.body = this.props.body
|
||||||
values.content_type = this.props.contentType
|
values.content_type = this.props.contentType
|
||||||
|
|
||||||
|
@ -469,7 +470,8 @@ class TheFormDef extends React.PureComponent {
|
||||||
})(
|
})(
|
||||||
<Select disabled={this.props.formDisabled} mode="multiple">
|
<Select disabled={this.props.formDisabled} mode="multiple">
|
||||||
{this.props.data[cs.ModelLists].hasOwnProperty("results") &&
|
{this.props.data[cs.ModelLists].hasOwnProperty("results") &&
|
||||||
[...this.props.data[cs.ModelLists].results].map((v, i) => (
|
[...this.props.data[cs.ModelLists].results].map((v) =>
|
||||||
|
(record.type !== cs.CampaignTypeOptin || v.optin === cs.ListOptinDouble) && (
|
||||||
<Select.Option value={v["id"]} key={v["id"]}>
|
<Select.Option value={v["id"]} key={v["id"]}>
|
||||||
{v["name"]}
|
{v["name"]}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
|
@ -684,6 +686,11 @@ class Campaign extends React.PureComponent {
|
||||||
>
|
>
|
||||||
{this.state.record.status}
|
{this.state.record.status}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
{this.state.record.type === cs.CampaignStatusOptin && (
|
||||||
|
<Tag className="campaign-type" color="geekblue">
|
||||||
|
{this.state.record.type}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
{this.state.record.name}
|
{this.state.record.name}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="text-tiny text-grey">
|
<span className="text-tiny text-grey">
|
||||||
|
|
|
@ -104,7 +104,14 @@ class Campaigns extends React.PureComponent {
|
||||||
const out = []
|
const out = []
|
||||||
out.push(
|
out.push(
|
||||||
<div className="name" key={`name-${record.id}`}>
|
<div className="name" key={`name-${record.id}`}>
|
||||||
<Link to={`/campaigns/${record.id}`}>{text}</Link>
|
<Link to={`/campaigns/${record.id}`}>{text}</Link>{" "}
|
||||||
|
{record.type === cs.CampaignStatusOptin && (
|
||||||
|
<Tooltip title="Opt-in campaign" placement="top">
|
||||||
|
<Tag className="campaign-type" color="geekblue">
|
||||||
|
{record.type}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-tiny">{record.subject}</span>
|
<span className="text-tiny">{record.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -269,7 +269,7 @@ class Lists extends React.PureComponent {
|
||||||
{record.optin === cs.ListOptinDouble && (
|
{record.optin === cs.ListOptinDouble && (
|
||||||
<p className="text-small">
|
<p className="text-small">
|
||||||
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
|
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
|
||||||
<Link to={`/campaigns/new?list_id=${record.id}`}>
|
<Link onClick={ e => { e.preventDefault(); this.makeOptinCampaign(record)} } to={`/campaigns/new?type=optin&list_id=${record.id}`}>
|
||||||
<Icon type="rocket" /> Send opt-in campaign
|
<Icon type="rocket" /> Send opt-in campaign
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -396,6 +396,45 @@ class Lists extends React.PureComponent {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeOptinCampaign = record => {
|
||||||
|
this.props
|
||||||
|
.modelRequest(
|
||||||
|
cs.ModelCampaigns,
|
||||||
|
cs.Routes.CreateCampaign,
|
||||||
|
cs.MethodPost,
|
||||||
|
{
|
||||||
|
type: cs.CampaignTypeOptin,
|
||||||
|
name: "Optin: "+ record.name,
|
||||||
|
subject: "Confirm your subscriptions",
|
||||||
|
messenger: "email",
|
||||||
|
content_type: cs.CampaignContentTypeRichtext,
|
||||||
|
lists: [record.id]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(resp => {
|
||||||
|
notification["success"]({
|
||||||
|
placement: cs.MsgPosition,
|
||||||
|
message: "Opt-in campaign created",
|
||||||
|
description: "Opt-in campaign created"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to the newly created campaign.
|
||||||
|
this.props.route.history.push({
|
||||||
|
pathname: cs.Routes.ViewCampaign.replace(
|
||||||
|
":id",
|
||||||
|
resp.data.data.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
notification["error"]({
|
||||||
|
placement: cs.MsgPosition,
|
||||||
|
message: "Error",
|
||||||
|
description: e.message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
handleHideForm = () => {
|
handleHideForm = () => {
|
||||||
this.setState({ formType: null })
|
this.setState({ formType: null })
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,12 @@ export const CampaignStatusRunning = "running"
|
||||||
export const CampaignStatusPaused = "paused"
|
export const CampaignStatusPaused = "paused"
|
||||||
export const CampaignStatusFinished = "finished"
|
export const CampaignStatusFinished = "finished"
|
||||||
export const CampaignStatusCancelled = "cancelled"
|
export const CampaignStatusCancelled = "cancelled"
|
||||||
|
export const CampaignStatusRegular = "regular"
|
||||||
|
export const CampaignStatusOptin = "optin"
|
||||||
|
|
||||||
|
export const CampaignTypeRegular = "regular"
|
||||||
|
export const CampaignTypeOptin = "optin"
|
||||||
|
|
||||||
export const CampaignContentTypeRichtext = "richtext"
|
export const CampaignContentTypeRichtext = "richtext"
|
||||||
export const CampaignContentTypeHTML = "html"
|
export const CampaignContentTypeHTML = "html"
|
||||||
export const CampaignContentTypePlain = "plain"
|
export const CampaignContentTypePlain = "plain"
|
||||||
|
|
|
@ -35,6 +35,8 @@ const (
|
||||||
CampaignStatusPaused = "paused"
|
CampaignStatusPaused = "paused"
|
||||||
CampaignStatusFinished = "finished"
|
CampaignStatusFinished = "finished"
|
||||||
CampaignStatusCancelled = "cancelled"
|
CampaignStatusCancelled = "cancelled"
|
||||||
|
CampaignTypeRegular = "regular"
|
||||||
|
CampaignTypeOptin = "optin"
|
||||||
|
|
||||||
// List.
|
// List.
|
||||||
ListTypePrivate = "private"
|
ListTypePrivate = "private"
|
||||||
|
@ -152,6 +154,7 @@ type Campaign struct {
|
||||||
CampaignMeta
|
CampaignMeta
|
||||||
|
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
|
Type string `db:"type" json:"type"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
Subject string `db:"subject" json:"subject"`
|
Subject string `db:"subject" json:"subject"`
|
||||||
FromEmail string `db:"from_email" json:"from_email"`
|
FromEmail string `db:"from_email" json:"from_email"`
|
||||||
|
|
|
@ -138,19 +138,17 @@ func handleOptinPage(c echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get lists by UUIDs.
|
// Get lists by UUIDs.
|
||||||
if err := app.Queries.GetListsByUUID.Select(&out.Lists, pq.StringArray(out.ListUUIDs)); err != nil {
|
if err := app.Queries.GetListsByOptin.Select(&out.Lists, models.ListOptinDouble, nil, pq.StringArray(out.ListUUIDs)); err != nil {
|
||||||
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
||||||
return c.Render(http.StatusInternalServerError, "message",
|
return c.Render(http.StatusInternalServerError, "message",
|
||||||
makeMsgTpl("Error", "",
|
makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
|
||||||
`Error fetching lists. Please retry.`))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, get the list of all unconfirmed lists for the subscriber.
|
// Otherwise, get the list of all unconfirmed lists for the subscriber.
|
||||||
if err := app.Queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, models.SubscriptionStatusUnconfirmed); err != nil {
|
if err := app.Queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, models.SubscriptionStatusUnconfirmed); err != nil {
|
||||||
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
||||||
return c.Render(http.StatusInternalServerError, "message",
|
return c.Render(http.StatusInternalServerError, "message",
|
||||||
makeMsgTpl("Error", "",
|
makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
|
||||||
`Error fetching lists. Please retry.`))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,6 @@ type Queries struct {
|
||||||
CreateList *sqlx.Stmt `query:"create-list"`
|
CreateList *sqlx.Stmt `query:"create-list"`
|
||||||
GetLists *sqlx.Stmt `query:"get-lists"`
|
GetLists *sqlx.Stmt `query:"get-lists"`
|
||||||
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
|
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
|
||||||
GetListsByUUID *sqlx.Stmt `query:"get-lists-by-uuid"`
|
|
||||||
UpdateList *sqlx.Stmt `query:"update-list"`
|
UpdateList *sqlx.Stmt `query:"update-list"`
|
||||||
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
|
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
|
||||||
DeleteLists *sqlx.Stmt `query:"delete-lists"`
|
DeleteLists *sqlx.Stmt `query:"delete-lists"`
|
||||||
|
|
|
@ -295,10 +295,11 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
|
||||||
GROUP BY lists.id ORDER BY lists.created_at OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
|
GROUP BY lists.id ORDER BY lists.created_at OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
|
||||||
|
|
||||||
-- name: get-lists-by-optin
|
-- name: get-lists-by-optin
|
||||||
SELECT * FROM lists WHERE optin=$1::list_optin AND id = ANY($2::INT[]) ORDER BY name;
|
-- Can have a list of IDs or a list of UUIDs.
|
||||||
|
SELECT * FROM lists WHERE optin=$1::list_optin AND
|
||||||
-- name: get-lists-by-uuid
|
(CASE WHEN $2::INT[] IS NOT NULL THEN id = ANY($2::INT[])
|
||||||
SELECT * FROM lists WHERE uuid = ANY($1::UUID[]) ORDER BY name;
|
WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[])
|
||||||
|
END) ORDER BY name;
|
||||||
|
|
||||||
-- name: create-list
|
-- name: create-list
|
||||||
INSERT INTO lists (uuid, name, type, optin, tags) VALUES($1, $2, $3, $4, $5) RETURNING id;
|
INSERT INTO lists (uuid, name, type, optin, tags) VALUES($1, $2, $3, $4, $5) RETURNING id;
|
||||||
|
|
|
@ -3,6 +3,7 @@ DROP TYPE IF EXISTS list_optin CASCADE; CREATE TYPE list_optin AS ENUM ('single'
|
||||||
DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blacklisted');
|
DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blacklisted');
|
||||||
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
|
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
|
||||||
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
|
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
|
||||||
|
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
||||||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
|
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
|
||||||
|
|
||||||
-- subscribers
|
-- subscribers
|
||||||
|
@ -79,6 +80,10 @@ CREATE TABLE campaigns (
|
||||||
status campaign_status NOT NULL DEFAULT 'draft',
|
status campaign_status NOT NULL DEFAULT 'draft',
|
||||||
tags VARCHAR(100)[],
|
tags VARCHAR(100)[],
|
||||||
|
|
||||||
|
-- The subscription statuses of subscribers to which a campaign will be sent.
|
||||||
|
-- For opt-in campaigns, this will be 'unsubscribed'.
|
||||||
|
type campaign_type DEFAULT 'regular',
|
||||||
|
|
||||||
-- The ID of the messenger backend used to send this campaign.
|
-- The ID of the messenger backend used to send this campaign.
|
||||||
messenger TEXT NOT NULL,
|
messenger TEXT NOT NULL,
|
||||||
template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
|
template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
|
||||||
|
|
Loading…
Reference in a new issue