2018-10-25 15:51:47 +02:00
|
|
|
package models
|
|
|
|
|
|
|
|
import (
|
|
|
|
"database/sql/driver"
|
|
|
|
"encoding/json"
|
2019-04-01 13:37:24 +02:00
|
|
|
"errors"
|
2018-10-25 15:51:47 +02:00
|
|
|
"fmt"
|
|
|
|
"html/template"
|
2018-10-31 13:54:21 +01:00
|
|
|
"regexp"
|
2018-11-02 11:38:54 +01:00
|
|
|
"strings"
|
2018-10-25 15:51:47 +02:00
|
|
|
|
|
|
|
"github.com/jmoiron/sqlx"
|
2019-01-04 08:06:55 +01:00
|
|
|
"github.com/jmoiron/sqlx/types"
|
2018-10-25 15:51:47 +02:00
|
|
|
"github.com/lib/pq"
|
|
|
|
|
|
|
|
null "gopkg.in/volatiletech/null.v6"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Enum values for various statuses.
|
|
|
|
const (
|
|
|
|
// Subscriber.
|
|
|
|
SubscriberStatusEnabled = "enabled"
|
|
|
|
SubscriberStatusDisabled = "disabled"
|
|
|
|
SubscriberStatusBlackListed = "blacklisted"
|
|
|
|
|
|
|
|
// Campaign.
|
|
|
|
CampaignStatusDraft = "draft"
|
|
|
|
CampaignStatusScheduled = "scheduled"
|
|
|
|
CampaignStatusRunning = "running"
|
|
|
|
CampaignStatusPaused = "paused"
|
|
|
|
CampaignStatusFinished = "finished"
|
|
|
|
CampaignStatusCancelled = "cancelled"
|
|
|
|
|
|
|
|
// List.
|
|
|
|
ListTypePrivate = "private"
|
|
|
|
ListTypePublic = "public"
|
|
|
|
|
|
|
|
// User.
|
|
|
|
UserTypeSuperadmin = "superadmin"
|
|
|
|
UserTypeUser = "user"
|
|
|
|
UserStatusEnabled = "enabled"
|
|
|
|
UserStatusDisabled = "disabled"
|
2018-10-31 13:54:21 +01:00
|
|
|
|
|
|
|
// BaseTpl is the name of the base template.
|
|
|
|
BaseTpl = "base"
|
|
|
|
|
|
|
|
// ContentTpl is the name of the compiled message.
|
|
|
|
ContentTpl = "content"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Regular expression for matching {{ Track "http://link.com" }} in the template
|
|
|
|
// and substituting it with {{ Track "http://link.com" .Campaign.UUID .Subscriber.UUID }}
|
|
|
|
// before compilation. This string gimmick is to make linking easier for users.
|
|
|
|
var (
|
2018-12-19 07:58:52 +01:00
|
|
|
regexpLinkTag = regexp.MustCompile("{{(\\s+)?TrackLink\\s+?(\"|`)(.+?)(\"|`)(\\s+)?}}")
|
|
|
|
regexpLinkTagReplace = `{{ TrackLink "$3" .Campaign.UUID .Subscriber.UUID }}`
|
2018-11-02 08:50:32 +01:00
|
|
|
|
|
|
|
regexpViewTag = regexp.MustCompile(`{{(\s+)?TrackView(\s+)?}}`)
|
|
|
|
regexpViewTagReplace = `{{ TrackView .Campaign.UUID .Subscriber.UUID }}`
|
2018-10-25 15:51:47 +02:00
|
|
|
)
|
|
|
|
|
2018-11-28 08:59:57 +01:00
|
|
|
// AdminNotifCallback is a callback function that's called
|
|
|
|
// when a campaign's status changes.
|
|
|
|
type AdminNotifCallback func(subject string, data map[string]interface{}) error
|
|
|
|
|
2018-10-25 15:51:47 +02:00
|
|
|
// Base holds common fields shared across models.
|
|
|
|
type Base struct {
|
|
|
|
ID int `db:"id" json:"id"`
|
|
|
|
CreatedAt null.Time `db:"created_at" json:"created_at"`
|
|
|
|
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// User represents an admin user.
|
|
|
|
type User struct {
|
|
|
|
Base
|
|
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Password string `json:"-"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
Status string `json:"status"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// Subscriber represents an e-mail subscriber.
|
|
|
|
type Subscriber struct {
|
|
|
|
Base
|
|
|
|
|
|
|
|
UUID string `db:"uuid" json:"uuid"`
|
|
|
|
Email string `db:"email" json:"email"`
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Attribs SubscriberAttribs `db:"attribs" json:"attribs"`
|
|
|
|
Status string `db:"status" json:"status"`
|
|
|
|
CampaignIDs pq.Int64Array `db:"campaigns" json:"-"`
|
2019-04-01 13:37:24 +02:00
|
|
|
Lists types.JSONText `db:"lists" json:"lists"`
|
2018-12-18 06:24:55 +01:00
|
|
|
|
|
|
|
// Pseudofield for getting the total number of subscribers
|
|
|
|
// in searches and queries.
|
|
|
|
Total int `db:"total" json:"-"`
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
2019-04-01 13:37:24 +02:00
|
|
|
type subLists struct {
|
|
|
|
SubscriberID int `db:"subscriber_id"`
|
|
|
|
Lists types.JSONText `db:"lists"`
|
|
|
|
}
|
2018-10-25 15:51:47 +02:00
|
|
|
|
|
|
|
// SubscriberAttribs is the map of key:value attributes of a subscriber.
|
|
|
|
type SubscriberAttribs map[string]interface{}
|
|
|
|
|
|
|
|
// Subscribers represents a slice of Subscriber.
|
|
|
|
type Subscribers []Subscriber
|
|
|
|
|
|
|
|
// List represents a mailing list.
|
|
|
|
type List struct {
|
|
|
|
Base
|
|
|
|
|
|
|
|
UUID string `db:"uuid" json:"uuid"`
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Type string `db:"type" json:"type"`
|
|
|
|
Tags pq.StringArray `db:"tags" json:"tags"`
|
|
|
|
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
|
|
|
|
SubscriberID int `db:"subscriber_id" json:"-"`
|
|
|
|
|
|
|
|
// This is only relevant when querying the lists of a subscriber.
|
|
|
|
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
|
2019-05-14 13:11:05 +02:00
|
|
|
|
|
|
|
// Pseudofield for getting the total number of subscribers
|
|
|
|
// in searches and queries.
|
|
|
|
Total int `db:"total" json:"-"`
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Campaign represents an e-mail campaign.
|
|
|
|
type Campaign struct {
|
|
|
|
Base
|
|
|
|
CampaignMeta
|
|
|
|
|
|
|
|
UUID string `db:"uuid" json:"uuid"`
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Subject string `db:"subject" json:"subject"`
|
|
|
|
FromEmail string `db:"from_email" json:"from_email"`
|
|
|
|
Body string `db:"body" json:"body,omitempty"`
|
|
|
|
SendAt null.Time `db:"send_at" json:"send_at"`
|
|
|
|
Status string `db:"status" json:"status"`
|
|
|
|
ContentType string `db:"content_type" json:"content_type"`
|
|
|
|
Tags pq.StringArray `db:"tags" json:"tags"`
|
|
|
|
TemplateID int `db:"template_id" json:"template_id"`
|
|
|
|
MessengerID string `db:"messenger" json:"messenger"`
|
|
|
|
|
|
|
|
// TemplateBody is joined in from templates by the next-campaigns query.
|
|
|
|
TemplateBody string `db:"template_body" json:"-"`
|
|
|
|
Tpl *template.Template `json:"-"`
|
2019-03-28 12:47:51 +01:00
|
|
|
|
|
|
|
// Pseudofield for getting the total number of subscribers
|
|
|
|
// in searches and queries.
|
|
|
|
Total int `db:"total" json:"-"`
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// CampaignMeta contains fields tracking a campaign's progress.
|
|
|
|
type CampaignMeta struct {
|
2019-04-01 13:37:24 +02:00
|
|
|
CampaignID int `db:"campaign_id" json:""`
|
|
|
|
Views int `db:"views" json:"views"`
|
|
|
|
Clicks int `db:"clicks" json:"clicks"`
|
|
|
|
|
|
|
|
// This is a list of {list_id, name} pairs unlike Subscriber.Lists[]
|
|
|
|
// because lists can be deleted after a campaign is finished, resulting
|
|
|
|
// in null lists data to be returned. For that reason, campaign_lists maintains
|
|
|
|
// campaign-list associations with a historical record of id + name that persist
|
|
|
|
// even after a list is deleted.
|
|
|
|
Lists types.JSONText `db:"lists" json:"lists"`
|
|
|
|
|
2018-10-25 15:51:47 +02:00
|
|
|
StartedAt null.Time `db:"started_at" json:"started_at"`
|
|
|
|
ToSend int `db:"to_send" json:"to_send"`
|
|
|
|
Sent int `db:"sent" json:"sent"`
|
|
|
|
}
|
|
|
|
|
2019-04-01 13:37:24 +02:00
|
|
|
// Campaigns represents a slice of Campaigns.
|
2018-10-25 15:51:47 +02:00
|
|
|
type Campaigns []Campaign
|
|
|
|
|
|
|
|
// Media represents an uploaded media item.
|
|
|
|
type Media struct {
|
|
|
|
ID int `db:"id" json:"id"`
|
|
|
|
UUID string `db:"uuid" json:"uuid"`
|
|
|
|
Filename string `db:"filename" json:"filename"`
|
|
|
|
Width int `db:"width" json:"width"`
|
|
|
|
Height int `db:"height" json:"height"`
|
|
|
|
CreatedAt null.Time `db:"created_at" json:"created_at"`
|
|
|
|
|
|
|
|
ThumbURI string `json:"thumb_uri"`
|
|
|
|
URI string `json:"uri"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// Template represents a reusable e-mail template.
|
|
|
|
type Template struct {
|
|
|
|
Base
|
|
|
|
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Body string `db:"body" json:"body,omitempty"`
|
|
|
|
IsDefault bool `db:"is_default" json:"is_default"`
|
|
|
|
}
|
|
|
|
|
2019-04-01 13:37:24 +02:00
|
|
|
// GetIDs returns the list of subscriber IDs.
|
|
|
|
func (subs Subscribers) GetIDs() []int {
|
|
|
|
IDs := make([]int, len(subs))
|
|
|
|
for i, c := range subs {
|
|
|
|
IDs[i] = c.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
return IDs
|
|
|
|
}
|
|
|
|
|
2018-10-25 15:51:47 +02:00
|
|
|
// LoadLists lazy loads the lists for all the subscribers
|
|
|
|
// in the Subscribers slice and attaches them to their []Lists property.
|
|
|
|
func (subs Subscribers) LoadLists(stmt *sqlx.Stmt) error {
|
2019-04-01 13:37:24 +02:00
|
|
|
var sl []subLists
|
|
|
|
err := stmt.Select(&sl, pq.Array(subs.GetIDs()))
|
2018-10-25 15:51:47 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-01 13:37:24 +02:00
|
|
|
if len(subs) != len(sl) {
|
|
|
|
return errors.New("campaign stats count does not match")
|
|
|
|
}
|
|
|
|
|
|
|
|
for i, s := range sl {
|
|
|
|
if s.SubscriberID == subs[i].ID {
|
|
|
|
subs[i].Lists = s.Lists
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Value returns the JSON marshalled SubscriberAttribs.
|
|
|
|
func (s SubscriberAttribs) Value() (driver.Value, error) {
|
|
|
|
return json.Marshal(s)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Scan unmarshals JSON into SubscriberAttribs.
|
|
|
|
func (s SubscriberAttribs) Scan(src interface{}) error {
|
|
|
|
if data, ok := src.([]byte); ok {
|
|
|
|
return json.Unmarshal(data, &s)
|
|
|
|
}
|
|
|
|
return fmt.Errorf("Could not not decode type %T -> %T", src, s)
|
|
|
|
}
|
2018-10-31 13:54:21 +01:00
|
|
|
|
2019-04-01 13:37:24 +02:00
|
|
|
// GetIDs returns the list of campaign IDs.
|
|
|
|
func (camps Campaigns) GetIDs() []int {
|
|
|
|
IDs := make([]int, len(camps))
|
|
|
|
for i, c := range camps {
|
|
|
|
IDs[i] = c.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
return IDs
|
|
|
|
}
|
|
|
|
|
|
|
|
// LoadStats lazy loads campaign stats onto a list of campaigns.
|
|
|
|
func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
|
|
|
|
var meta []CampaignMeta
|
|
|
|
if err := stmt.Select(&meta, pq.Array(camps.GetIDs())); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(camps) != len(meta) {
|
|
|
|
return errors.New("campaign stats count does not match")
|
|
|
|
}
|
|
|
|
|
|
|
|
for i, c := range meta {
|
|
|
|
if c.CampaignID == camps[i].ID {
|
|
|
|
camps[i].Lists = c.Lists
|
|
|
|
camps[i].Views = c.Views
|
|
|
|
camps[i].Clicks = c.Clicks
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-10-31 13:54:21 +01:00
|
|
|
// CompileTemplate compiles a campaign body template into its base
|
2018-11-26 13:06:05 +01:00
|
|
|
// template and sets the resultant template to Campaign.Tpl.
|
2018-10-31 13:54:21 +01:00
|
|
|
func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
|
|
|
// Compile the base template.
|
|
|
|
t := regexpLinkTag.ReplaceAllString(c.TemplateBody, regexpLinkTagReplace)
|
2018-11-03 14:25:19 +01:00
|
|
|
t = regexpViewTag.ReplaceAllString(t, regexpViewTagReplace)
|
2018-10-31 13:54:21 +01:00
|
|
|
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(t)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error compiling base template: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Compile the campaign message.
|
|
|
|
t = regexpLinkTag.ReplaceAllString(c.Body, regexpLinkTagReplace)
|
2018-11-03 14:25:19 +01:00
|
|
|
t = regexpViewTag.ReplaceAllString(t, regexpViewTagReplace)
|
2018-10-31 13:54:21 +01:00
|
|
|
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(t)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error compiling message: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error inserting child template: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
c.Tpl = out
|
|
|
|
return nil
|
|
|
|
}
|
2018-11-02 11:38:54 +01:00
|
|
|
|
|
|
|
// FirstName splits the name by spaces and returns the first chunk
|
|
|
|
// of the name that's greater than 2 characters in length, assuming
|
|
|
|
// that it is the subscriber's first name.
|
|
|
|
func (s *Subscriber) FirstName() string {
|
|
|
|
for _, s := range strings.Split(s.Name, " ") {
|
|
|
|
if len(s) > 2 {
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return s.Name
|
|
|
|
}
|
|
|
|
|
|
|
|
// LastName splits the name by spaces and returns the last chunk
|
|
|
|
// of the name that's greater than 2 characters in length, assuming
|
|
|
|
// that it is the subscriber's last name.
|
|
|
|
func (s *Subscriber) LastName() string {
|
|
|
|
chunks := strings.Split(s.Name, " ")
|
|
|
|
for i := len(chunks) - 1; i >= 0; i-- {
|
|
|
|
chunk := chunks[i]
|
|
|
|
if len(chunk) > 2 {
|
|
|
|
return chunk
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return s.Name
|
|
|
|
}
|