Compare commits

...

3 commits

Author SHA1 Message Date
Hiram Chirino 8e11abc59d
Merge 5d89b38138 into 16f4dfd3e9 2024-09-19 12:27:25 +05:30
Bowrna 16f4dfd3e9
Fix incorrect bulk blocklisting behaviour (#2041). Fixes #1841 2024-09-19 10:56:56 +05:30
Hiram Chirino 5d89b38138 feat: support configuring a welcome email on lists.
This welcome template will be used to send a welcome email to new list members once the subscription is confirmed (if confirmation is required).

Signed-off-by: Hiram Chirino <hiram@hiramchirino.com>
2024-04-06 11:28:55 -04:00
18 changed files with 223 additions and 56 deletions

View file

@ -74,6 +74,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
models.ListOptinSingle,
pq.StringArray{"test"},
"",
nil,
); err != nil {
lo.Fatalf("error creating list: %v", err)
}
@ -84,6 +85,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
models.ListOptinDouble,
pq.StringArray{"test"},
"",
nil,
); err != nil {
lo.Fatalf("error creating list: %v", err)
}

View file

@ -205,6 +205,7 @@ func main() {
app.core = core.New(cOpt, &core.Hooks{
SendOptinConfirmation: sendOptinConfirmationHook(app),
SendTxMessage: sendTxMessageHook(app),
})
app.queries = queries

View file

@ -22,12 +22,13 @@ const (
// subQueryReq is a "catch all" struct for reading various
// subscriber related requests.
type subQueryReq struct {
Query string `json:"query"`
ListIDs []int `json:"list_ids"`
TargetListIDs []int `json:"target_list_ids"`
SubscriberIDs []int `json:"ids"`
Action string `json:"action"`
Status string `json:"status"`
Query string `json:"query"`
ListIDs []int `json:"list_ids"`
TargetListIDs []int `json:"target_list_ids"`
SubscriberIDs []int `json:"ids"`
Action string `json:"action"`
Status string `json:"status"`
SubscriptionStatus string `json:"subscription_status"`
}
// subProfileData represents a subscriber's collated data in JSON
@ -415,7 +416,7 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
return err
}
if err := app.core.DeleteSubscribersByQuery(req.Query, req.ListIDs); err != nil {
if err := app.core.DeleteSubscribersByQuery(req.Query, req.ListIDs, req.SubscriptionStatus); err != nil {
return err
}
@ -434,7 +435,7 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
return err
}
if err := app.core.BlocklistSubscribersByQuery(req.Query, req.ListIDs); err != nil {
if err := app.core.BlocklistSubscribersByQuery(req.Query, req.ListIDs, req.SubscriptionStatus); err != nil {
return err
}
@ -461,11 +462,11 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
var err error
switch req.Action {
case "add":
err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.Status)
err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.Status, req.SubscriptionStatus)
case "remove":
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus)
case "unsubscribe":
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
@ -631,3 +632,11 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
return len(lists), nil
}
}
// sendTxMessageHook returns an enclosed callback that sends tx e-mails.
// This is plugged into the 'core' package to send welcome messages when a new subscriber is confirmed.
func sendTxMessageHook(app *App) func(tx models.TxMessage) error {
return func(tx models.TxMessage) error {
return sendTxMessage(app, tx)
}
}

View file

@ -66,6 +66,15 @@ func handleSendTxMessage(c echo.Context) error {
return err
}
err := sendTxMessage(app, m)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
func sendTxMessage(app *App, m models.TxMessage) error {
// Validate input.
if r, err := validateTxMessage(m, app); err != nil {
return err
@ -156,8 +165,7 @@ func handleSendTxMessage(c echo.Context) error {
if len(notFound) > 0 {
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
}
return c.JSON(http.StatusOK, okResp{true})
return nil
}
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {

View file

@ -38,6 +38,7 @@ var migList = []migFunc{
{"v2.4.0", migrations.V2_4_0},
{"v2.5.0", migrations.V2_5_0},
{"v3.0.0", migrations.V3_0_0},
{"v3.1.0", migrations.V3_1_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View file

@ -235,3 +235,34 @@ export default class Utils {
localStorage.setItem(prefKey, JSON.stringify(p));
};
}
export function snakeString(str) {
return str.replace(/[A-Z]/g, (match, offset) => (offset ? '_' : '') + match.toLowerCase());
}
export function snakeKeys(obj, testFunc, keys) {
if (obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((o) => snakeKeys(o, testFunc, `${keys || ''}.*`));
}
if (obj.constructor === Object) {
return Object.keys(obj).reduce((result, key) => {
const keyPath = `${keys || ''}.${key}`;
let k = key;
// If there's no testfunc or if a function is defined and it returns true, convert.
if (testFunc === undefined || testFunc(keyPath)) {
k = snakeString(key);
}
return {
...result,
[k]: snakeKeys(obj[key], testFunc, keyPath),
};
}, {});
}
return obj;
}

View file

@ -44,6 +44,17 @@
</b-select>
</b-field>
<b-field :label="$tc('lists.welcomeTemplate')" label-position="on-border" :message="$t('lists.welcomeTemplateHelp')">
<b-select v-model="form.welcomeTemplateId" name="template">
<option :value="null">{{ $tc('globals.terms.none') }}</option>
<template v-for="t in templates">
<option v-if="t.type === 'tx'" :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" />
@ -70,6 +81,7 @@
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
import { snakeKeys } from '../utils';
export default Vue.extend({
name: 'ListForm',
@ -91,6 +103,7 @@ export default Vue.extend({
type: 'private',
optin: 'single',
tags: [],
welcomeTemplateId: null,
},
};
},
@ -106,7 +119,7 @@ export default Vue.extend({
},
createList() {
this.$api.createList(this.form).then((data) => {
this.$api.createList(snakeKeys(this.form)).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@ -114,7 +127,8 @@ export default Vue.extend({
},
updateList() {
this.$api.updateList({ id: this.data.id, ...this.form }).then((data) => {
const form = snakeKeys(this.form);
this.$api.updateList({ id: this.data.id, ...form }).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
@ -123,12 +137,15 @@ export default Vue.extend({
},
computed: {
...mapState(['loading']),
...mapState(['loading', 'templates']),
},
mounted() {
this.form = { ...this.form, ...this.$props.data };
// Get the templates list.
this.$api.getTemplates();
this.$nextTick(() => {
this.$refs.focus.focus();
});

View file

@ -375,6 +375,7 @@ export default Vue.extend({
this.$api.blocklistSubscribersByQuery({
query: this.queryParams.queryExp,
list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
subscription_status: this.queryParams.subStatus,
}).then(() => this.querySubscribers());
};
}
@ -426,6 +427,7 @@ export default Vue.extend({
this.$api.deleteSubscribersByQuery({
query: this.queryParams.queryExp,
list_ids: this.queryParams.listID ? [this.queryParams.listID] : null,
subscription_status: this.queryParams.subStatus,
}).then(() => {
this.querySubscribers();
@ -460,6 +462,7 @@ export default Vue.extend({
} else {
// 'All' is selected, perform by query.
data.query = this.queryParams.queryExp;
data.subscription_status = this.queryParams.subStatus;
fn = this.$api.addSubscribersToListsByQuery;
}
@ -503,7 +506,6 @@ export default Vue.extend({
// Get subscribers on load.
this.querySubscribers();
}
if (this.$route.query.subscription_status) {
this.queryParams.subStatus = this.$route.query.subscription_status;
}

View file

@ -276,6 +276,8 @@
"lists.typeHelp": "Public lists are open to the world to subscribe and their names may appear on public pages such as the subscription management page.",
"lists.types.private": "Private",
"lists.types.public": "Public",
"lists.welcomeTemplate": "Welcome Template",
"lists.welcomeTemplateHelp": "If enabled, sends an e-mail to new confirmed subscribers using the selected template.",
"logs.title": "Logs",
"maintenance.help": "Some actions may take a while to complete depending on the amount of data.",
"maintenance.maintenance.unconfirmedOptins": "Unconfirmed opt-in subscriptions",

View file

@ -50,6 +50,7 @@ type Constants struct {
// Hooks contains external function hooks that are required by the core package.
type Hooks struct {
SendOptinConfirmation func(models.Subscriber, []int) (int, error)
SendTxMessage func(tx models.TxMessage) error
}
// Opt contains the controllers required to start the core.

View file

@ -136,7 +136,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// Insert and read ID.
var newID int
l.UUID = uu.String()
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil {
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID); err != nil {
c.log.Printf("error creating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
@ -147,7 +147,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// UpdateList updates a given list.
func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description)
res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID)
if err != nil {
c.log.Printf("error updating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,

View file

@ -294,6 +294,8 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
hasOptin = num > 0
}
c.sendWelcomeMessage(out.UUID, map[int]bool{})
return out, hasOptin, nil
}
@ -352,6 +354,9 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
}
}
// keep track of lists have been sent welcome emails..
welcomesSent := c.getWelcomesSent(sub.UUID)
_, err := c.q.UpdateSubscriberWithLists.Exec(id,
sub.Email,
strings.TrimSpace(sub.Name),
@ -379,9 +384,69 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
hasOptin = num > 0
}
// Send welcome tx messages.
c.sendWelcomeMessage(sub.UUID, welcomesSent)
return out, hasOptin, nil
}
func (c *Core) getWelcomesSent(subUUID string) map[int]bool {
welcomesSent := map[int]bool{}
if listSubs, err := c.GetSubscriptions(0, subUUID, false); err == nil {
for _, listSub := range listSubs {
if listSub.WelcomeTemplateID == nil {
continue
}
if !(listSub.Optin == models.ListOptinSingle || listSub.SubscriptionStatus.String == models.SubscriptionStatusConfirmed) {
continue
}
welcomesSent[listSub.ID] = true
}
}
return welcomesSent
}
func (c *Core) sendWelcomeMessage(subUUID string, welcomesSent map[int]bool) {
listSubs, err := c.GetSubscriptions(0, subUUID, false)
if err != nil {
c.log.Printf("error getting the subscriber's lists: %v", err)
}
for _, listSub := range listSubs {
if listSub.WelcomeTemplateID == nil {
continue
}
if welcomesSent[listSub.ID] {
continue
}
if !(listSub.Optin == models.ListOptinSingle || listSub.SubscriptionStatus.String == models.SubscriptionStatusConfirmed) {
continue
}
data := map[string]interface{}{}
if len(listSub.Meta) > 0 {
err := json.Unmarshal(listSub.Meta, &data)
if err != nil {
c.log.Printf("error unmarshalling sub meta: %v", err)
}
}
sub, err := c.GetSubscriber(0, subUUID, "")
if err != nil {
c.log.Printf("error sending welcome messages: subscriber not found %v", err)
}
err = c.h.SendTxMessage(models.TxMessage{
TemplateID: *listSub.WelcomeTemplateID,
SubscriberIDs: []int{sub.ID},
Data: data,
})
if err != nil {
c.log.Printf("error sending welcome messages: %v", err)
}
}
}
// BlocklistSubscribers blocklists the given list of subscribers.
func (c *Core) BlocklistSubscribers(subIDs []int) error {
if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
@ -394,8 +459,8 @@ func (c *Core) BlocklistSubscribers(subIDs []int) error {
}
// BlocklistSubscribersByQuery blocklists the given list of subscribers.
func (c *Core) BlocklistSubscribersByQuery(query string, listIDs []int) error {
if err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.BlocklistSubscribersByQuery, listIDs, c.db); err != nil {
func (c *Core) BlocklistSubscribersByQuery(query string, listIDs []int, subStatus string) error {
if err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.BlocklistSubscribersByQuery, listIDs, c.db, subStatus); err != nil {
c.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
@ -423,8 +488,8 @@ func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
}
// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression.
func (c *Core) DeleteSubscribersByQuery(query string, listIDs []int) error {
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscribersByQuery, listIDs, c.db)
func (c *Core) DeleteSubscribersByQuery(query string, listIDs []int, subStatus string) error {
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscribersByQuery, listIDs, c.db, subStatus)
if err != nil {
c.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
@ -451,12 +516,15 @@ func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, met
meta = models.JSON{}
}
welcomesSent := c.getWelcomesSent(subUUID)
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
c.log.Printf("error confirming subscription: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
c.sendWelcomeMessage(subUUID, welcomesSent)
return nil
}

View file

@ -35,12 +35,12 @@ func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) AddSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int, status string) error {
func (c *Core) AddSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int, status string, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, pq.Array(targetListIDs), status)
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs), status)
if err != nil {
c.log.Printf("error adding subscriptions by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
@ -64,12 +64,12 @@ func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int) error {
func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, pq.Array(targetListIDs))
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
if err != nil {
c.log.Printf("error deleting subscriptions by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
@ -92,12 +92,12 @@ func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error
// UnsubscribeListsByQuery sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) UnsubscribeListsByQuery(query string, sourceListIDs, targetListIDs []int) error {
func (c *Core) UnsubscribeListsByQuery(query string, sourceListIDs, targetListIDs []int, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, pq.Array(targetListIDs))
err := c.q.ExecSubQueryTpl(sanitizeSQLExp(query), c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
if err != nil {
c.log.Printf("error unsubscribing from lists by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,

View file

@ -0,0 +1,22 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V3_1_0 performs the DB migrations.
func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
ALTER TABLE lists ADD COLUMN IF NOT EXISTS welcome_template_id INTEGER NULL
REFERENCES templates(id) ON DELETE SET NULL ON UPDATE CASCADE;
`); err != nil {
return err
}
return nil
}

View file

@ -222,6 +222,8 @@ type List struct {
SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
SubscriberID int `db:"subscriber_id" json:"-"`
WelcomeTemplateID *int `db:"welcome_template_id" json:"welcome_template_id"`
// This is only relevant when querying the lists of a subscriber.
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"`

View file

@ -114,7 +114,7 @@ type Queries struct {
// out of it using the raw `query-subscribers-template` query template.
// While doing this, a readonly transaction is created and the query is
// dry run on it to ensure that it is indeed readonly.
func (q *Queries) CompileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, error) {
func (q *Queries) CompileSubscriberQueryTpl(exp string, db *sqlx.DB, subStatus string) (string, error) {
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return "", err
@ -126,19 +126,18 @@ func (q *Queries) CompileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, er
exp = " AND " + exp
}
stmt := fmt.Sprintf(q.QuerySubscribersTpl, exp)
if _, err := tx.Exec(stmt, true, pq.Int64Array{}); err != nil {
if _, err := tx.Exec(stmt, true, pq.Int64Array{}, subStatus); err != nil {
return "", err
}
return stmt, nil
}
// compileSubscriberQueryTpl takes an arbitrary WHERE expressions and a subscriber
// query template that depends on the filter (eg: delete by query, blocklist by query etc.)
// combines and executes them.
func (q *Queries) ExecSubQueryTpl(exp, tpl string, listIDs []int, db *sqlx.DB, args ...interface{}) error {
func (q *Queries) ExecSubQueryTpl(exp, tpl string, listIDs []int, db *sqlx.DB, subStatus string, args ...interface{}) error {
// Perform a dry run.
filterExp, err := q.CompileSubscriberQueryTpl(exp, db)
filterExp, err := q.CompileSubscriberQueryTpl(exp, db, subStatus)
if err != nil {
return err
}
@ -148,10 +147,9 @@ func (q *Queries) ExecSubQueryTpl(exp, tpl string, listIDs []int, db *sqlx.DB, a
}
// First argument is the boolean indicating if the query is a dry run.
a := append([]interface{}{false, pq.Array(listIDs)}, args...)
a := append([]interface{}{false, pq.Array(listIDs), subStatus}, args...)
if _, err := db.Exec(fmt.Sprintf(tpl, filterExp), a...); err != nil {
return err
}
return nil
}

View file

@ -370,6 +370,7 @@ ON (
-- Optional list filtering.
(CASE WHEN CARDINALITY($2::INT[]) > 0 THEN true ELSE false END)
AND subscriber_lists.subscriber_id = subscribers.id
AND ($3 = '' OR subscriber_lists.status = $3::subscription_status)
)
WHERE subscriber_lists.list_id = ALL($2::INT[]) %s
LIMIT (CASE WHEN $1 THEN 1 END)
@ -393,20 +394,20 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
-- raw: true
WITH subs AS (%s)
INSERT INTO subscriber_lists (subscriber_id, list_id, status)
(SELECT a, b, (CASE WHEN $4 != '' THEN $4::subscription_status ELSE 'unconfirmed' END) FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($3::INT[]) b)
(SELECT a, b, (CASE WHEN $5 != '' THEN $5::subscription_status ELSE 'unconfirmed' END) FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($4::INT[]) b)
ON CONFLICT (subscriber_id, list_id) DO NOTHING;
-- name: delete-subscriptions-by-query
-- raw: true
WITH subs AS (%s)
DELETE FROM subscriber_lists
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($3::INT[]) b);
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($4::INT[]) b);
-- name: unsubscribe-subscribers-from-lists-by-query
-- raw: true
WITH subs AS (%s)
UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($3::INT[]) b);
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST(ARRAY(SELECT id FROM subs)) a, UNNEST($4::INT[]) b);
-- lists
@ -446,7 +447,7 @@ SELECT * FROM lists WHERE (CASE WHEN $1 != '' THEN optin=$1::list_optin ELSE TRU
END) ORDER BY name;
-- name: create-list
INSERT INTO lists (uuid, name, type, optin, tags, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING id;
INSERT INTO lists (uuid, name, type, optin, tags, description, welcome_template_id) VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING id;
-- name: update-list
UPDATE lists SET
@ -455,6 +456,7 @@ UPDATE lists SET
optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END),
tags=$5::VARCHAR(100)[],
description=(CASE WHEN $6 != '' THEN $6 ELSE description END),
welcome_template_id=$7,
updated_at=NOW()
WHERE id = $1;

View file

@ -26,6 +26,21 @@ DROP INDEX IF EXISTS idx_subs_status; CREATE INDEX idx_subs_status ON subscriber
DROP INDEX IF EXISTS idx_subs_created_at; CREATE INDEX idx_subs_created_at ON subscribers(created_at);
DROP INDEX IF EXISTS idx_subs_updated_at; CREATE INDEX idx_subs_updated_at ON subscribers(updated_at);
-- templates
DROP TABLE IF EXISTS templates CASCADE;
CREATE TABLE templates (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type template_type NOT NULL DEFAULT 'campaign',
subject TEXT NOT NULL,
body TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true;
-- lists
DROP TABLE IF EXISTS lists CASCADE;
CREATE TABLE lists (
@ -37,6 +52,8 @@ CREATE TABLE lists (
tags VARCHAR(100)[],
description TEXT NOT NULL DEFAULT '',
welcome_template_id INTEGER NULL REFERENCES templates(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@ -63,22 +80,6 @@ DROP INDEX IF EXISTS idx_sub_lists_sub_id; CREATE INDEX idx_sub_lists_sub_id ON
DROP INDEX IF EXISTS idx_sub_lists_list_id; CREATE INDEX idx_sub_lists_list_id ON subscriber_lists(list_id);
DROP INDEX IF EXISTS idx_sub_lists_status; CREATE INDEX idx_sub_lists_status ON subscriber_lists(status);
-- templates
DROP TABLE IF EXISTS templates CASCADE;
CREATE TABLE templates (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type template_type NOT NULL DEFAULT 'campaign',
subject TEXT NOT NULL,
body TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true;
-- campaigns
DROP TABLE IF EXISTS campaigns CASCADE;
CREATE TABLE campaigns (