models.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. package models
  2. import (
  3. "database/sql/driver"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "regexp"
  9. "strings"
  10. "github.com/jmoiron/sqlx"
  11. "github.com/jmoiron/sqlx/types"
  12. "github.com/lib/pq"
  13. null "gopkg.in/volatiletech/null.v6"
  14. )
  15. // Enum values for various statuses.
  16. const (
  17. // Subscriber.
  18. SubscriberStatusEnabled = "enabled"
  19. SubscriberStatusDisabled = "disabled"
  20. SubscriberStatusBlockListed = "blocklisted"
  21. // Subscription.
  22. SubscriptionStatusUnconfirmed = "unconfirmed"
  23. SubscriptionStatusConfirmed = "confirmed"
  24. SubscriptionStatusUnsubscribed = "unsubscribed"
  25. // Campaign.
  26. CampaignStatusDraft = "draft"
  27. CampaignStatusScheduled = "scheduled"
  28. CampaignStatusRunning = "running"
  29. CampaignStatusPaused = "paused"
  30. CampaignStatusFinished = "finished"
  31. CampaignStatusCancelled = "cancelled"
  32. CampaignTypeRegular = "regular"
  33. CampaignTypeOptin = "optin"
  34. // List.
  35. ListTypePrivate = "private"
  36. ListTypePublic = "public"
  37. ListOptinSingle = "single"
  38. ListOptinDouble = "double"
  39. // User.
  40. UserTypeSuperadmin = "superadmin"
  41. UserTypeUser = "user"
  42. UserStatusEnabled = "enabled"
  43. UserStatusDisabled = "disabled"
  44. // BaseTpl is the name of the base template.
  45. BaseTpl = "base"
  46. // ContentTpl is the name of the compiled message.
  47. ContentTpl = "content"
  48. )
  49. // regTplFunc represents contains a regular expression for wrapping and
  50. // substituting a Go template function from the user's shorthand to a full
  51. // function call.
  52. type regTplFunc struct {
  53. regExp *regexp.Regexp
  54. replace string
  55. }
  56. // Regular expression for matching {{ Track "http://link.com" }} in the template
  57. // and substituting it with {{ Track "http://link.com" .Campaign.UUID .Subscriber.UUID }}
  58. // before compilation. This string gimmick is to make linking easier for users.
  59. var regTplFuncs = []regTplFunc{
  60. regTplFunc{
  61. regExp: regexp.MustCompile("{{(\\s+)?TrackLink\\s+?(\"|`)(.+?)(\"|`)(\\s+)?}}"),
  62. replace: `{{ TrackLink "$3" . }}`,
  63. },
  64. regTplFunc{
  65. regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|OptinURL|MessageURL)(\s+)?}}`),
  66. replace: `{{ $2 . }}`,
  67. },
  68. }
  69. // AdminNotifCallback is a callback function that's called
  70. // when a campaign's status changes.
  71. type AdminNotifCallback func(subject string, data interface{}) error
  72. // Base holds common fields shared across models.
  73. type Base struct {
  74. ID int `db:"id" json:"id"`
  75. CreatedAt null.Time `db:"created_at" json:"created_at"`
  76. UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
  77. }
  78. // User represents an admin user.
  79. type User struct {
  80. Base
  81. Email string `json:"email"`
  82. Name string `json:"name"`
  83. Password string `json:"-"`
  84. Type string `json:"type"`
  85. Status string `json:"status"`
  86. }
  87. // Subscriber represents an e-mail subscriber.
  88. type Subscriber struct {
  89. Base
  90. UUID string `db:"uuid" json:"uuid"`
  91. Email string `db:"email" json:"email"`
  92. Name string `db:"name" json:"name"`
  93. Attribs SubscriberAttribs `db:"attribs" json:"attribs"`
  94. Status string `db:"status" json:"status"`
  95. CampaignIDs pq.Int64Array `db:"campaigns" json:"-"`
  96. Lists types.JSONText `db:"lists" json:"lists"`
  97. // Pseudofield for getting the total number of subscribers
  98. // in searches and queries.
  99. Total int `db:"total" json:"-"`
  100. }
  101. type subLists struct {
  102. SubscriberID int `db:"subscriber_id"`
  103. Lists types.JSONText `db:"lists"`
  104. }
  105. // SubscriberAttribs is the map of key:value attributes of a subscriber.
  106. type SubscriberAttribs map[string]interface{}
  107. // Subscribers represents a slice of Subscriber.
  108. type Subscribers []Subscriber
  109. // List represents a mailing list.
  110. type List struct {
  111. Base
  112. UUID string `db:"uuid" json:"uuid"`
  113. Name string `db:"name" json:"name"`
  114. Type string `db:"type" json:"type"`
  115. Optin string `db:"optin" json:"optin"`
  116. Tags pq.StringArray `db:"tags" json:"tags"`
  117. SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
  118. SubscriberID int `db:"subscriber_id" json:"-"`
  119. // This is only relevant when querying the lists of a subscriber.
  120. SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
  121. // Pseudofield for getting the total number of subscribers
  122. // in searches and queries.
  123. Total int `db:"total" json:"-"`
  124. }
  125. // Campaign represents an e-mail campaign.
  126. type Campaign struct {
  127. Base
  128. CampaignMeta
  129. UUID string `db:"uuid" json:"uuid"`
  130. Type string `db:"type" json:"type"`
  131. Name string `db:"name" json:"name"`
  132. Subject string `db:"subject" json:"subject"`
  133. FromEmail string `db:"from_email" json:"from_email"`
  134. Body string `db:"body" json:"body"`
  135. SendAt null.Time `db:"send_at" json:"send_at"`
  136. Status string `db:"status" json:"status"`
  137. ContentType string `db:"content_type" json:"content_type"`
  138. Tags pq.StringArray `db:"tags" json:"tags"`
  139. TemplateID int `db:"template_id" json:"template_id"`
  140. MessengerID string `db:"messenger" json:"messenger"`
  141. // TemplateBody is joined in from templates by the next-campaigns query.
  142. TemplateBody string `db:"template_body" json:"-"`
  143. Tpl *template.Template `json:"-"`
  144. SubjectTpl *template.Template `json:"-"`
  145. // Pseudofield for getting the total number of subscribers
  146. // in searches and queries.
  147. Total int `db:"total" json:"-"`
  148. }
  149. // CampaignMeta contains fields tracking a campaign's progress.
  150. type CampaignMeta struct {
  151. CampaignID int `db:"campaign_id" json:"-"`
  152. Views int `db:"views" json:"views"`
  153. Clicks int `db:"clicks" json:"clicks"`
  154. // This is a list of {list_id, name} pairs unlike Subscriber.Lists[]
  155. // because lists can be deleted after a campaign is finished, resulting
  156. // in null lists data to be returned. For that reason, campaign_lists maintains
  157. // campaign-list associations with a historical record of id + name that persist
  158. // even after a list is deleted.
  159. Lists types.JSONText `db:"lists" json:"lists"`
  160. StartedAt null.Time `db:"started_at" json:"started_at"`
  161. ToSend int `db:"to_send" json:"to_send"`
  162. Sent int `db:"sent" json:"sent"`
  163. }
  164. // Campaigns represents a slice of Campaigns.
  165. type Campaigns []Campaign
  166. // Template represents a reusable e-mail template.
  167. type Template struct {
  168. Base
  169. Name string `db:"name" json:"name"`
  170. Body string `db:"body" json:"body,omitempty"`
  171. IsDefault bool `db:"is_default" json:"is_default"`
  172. }
  173. // GetIDs returns the list of subscriber IDs.
  174. func (subs Subscribers) GetIDs() []int {
  175. IDs := make([]int, len(subs))
  176. for i, c := range subs {
  177. IDs[i] = c.ID
  178. }
  179. return IDs
  180. }
  181. // LoadLists lazy loads the lists for all the subscribers
  182. // in the Subscribers slice and attaches them to their []Lists property.
  183. func (subs Subscribers) LoadLists(stmt *sqlx.Stmt) error {
  184. var sl []subLists
  185. err := stmt.Select(&sl, pq.Array(subs.GetIDs()))
  186. if err != nil {
  187. return err
  188. }
  189. if len(subs) != len(sl) {
  190. return errors.New("campaign stats count does not match")
  191. }
  192. for i, s := range sl {
  193. if s.SubscriberID == subs[i].ID {
  194. subs[i].Lists = s.Lists
  195. }
  196. }
  197. return nil
  198. }
  199. // Value returns the JSON marshalled SubscriberAttribs.
  200. func (s SubscriberAttribs) Value() (driver.Value, error) {
  201. return json.Marshal(s)
  202. }
  203. // Scan unmarshals JSON into SubscriberAttribs.
  204. func (s SubscriberAttribs) Scan(src interface{}) error {
  205. if data, ok := src.([]byte); ok {
  206. return json.Unmarshal(data, &s)
  207. }
  208. return fmt.Errorf("Could not not decode type %T -> %T", src, s)
  209. }
  210. // GetIDs returns the list of campaign IDs.
  211. func (camps Campaigns) GetIDs() []int {
  212. IDs := make([]int, len(camps))
  213. for i, c := range camps {
  214. IDs[i] = c.ID
  215. }
  216. return IDs
  217. }
  218. // LoadStats lazy loads campaign stats onto a list of campaigns.
  219. func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
  220. var meta []CampaignMeta
  221. if err := stmt.Select(&meta, pq.Array(camps.GetIDs())); err != nil {
  222. return err
  223. }
  224. if len(camps) != len(meta) {
  225. return errors.New("campaign stats count does not match")
  226. }
  227. for i, c := range meta {
  228. if c.CampaignID == camps[i].ID {
  229. camps[i].Lists = c.Lists
  230. camps[i].Views = c.Views
  231. camps[i].Clicks = c.Clicks
  232. }
  233. }
  234. return nil
  235. }
  236. // CompileTemplate compiles a campaign body template into its base
  237. // template and sets the resultant template to Campaign.Tpl.
  238. func (c *Campaign) CompileTemplate(f template.FuncMap) error {
  239. // Compile the base template.
  240. body := c.TemplateBody
  241. for _, r := range regTplFuncs {
  242. body = r.regExp.ReplaceAllString(body, r.replace)
  243. }
  244. baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body)
  245. if err != nil {
  246. return fmt.Errorf("error compiling base template: %v", err)
  247. }
  248. // Compile the campaign message.
  249. body = c.Body
  250. for _, r := range regTplFuncs {
  251. body = r.regExp.ReplaceAllString(body, r.replace)
  252. }
  253. msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(body)
  254. if err != nil {
  255. return fmt.Errorf("error compiling message: %v", err)
  256. }
  257. out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
  258. if err != nil {
  259. return fmt.Errorf("error inserting child template: %v", err)
  260. }
  261. // If the subject line has a template string, compile it.
  262. if strings.Contains(c.Subject, "{{") {
  263. subj := c.Subject
  264. for _, r := range regTplFuncs {
  265. subj = r.regExp.ReplaceAllString(subj, r.replace)
  266. }
  267. subjTpl, err := template.New(ContentTpl).Funcs(f).Parse(subj)
  268. if err != nil {
  269. return fmt.Errorf("error compiling subject: %v", err)
  270. }
  271. c.SubjectTpl = subjTpl
  272. }
  273. c.Tpl = out
  274. return nil
  275. }
  276. // FirstName splits the name by spaces and returns the first chunk
  277. // of the name that's greater than 2 characters in length, assuming
  278. // that it is the subscriber's first name.
  279. func (s Subscriber) FirstName() string {
  280. for _, s := range strings.Split(s.Name, " ") {
  281. if len(s) > 2 {
  282. return s
  283. }
  284. }
  285. return s.Name
  286. }
  287. // LastName splits the name by spaces and returns the last chunk
  288. // of the name that's greater than 2 characters in length, assuming
  289. // that it is the subscriber's last name.
  290. func (s Subscriber) LastName() string {
  291. chunks := strings.Split(s.Name, " ")
  292. for i := len(chunks) - 1; i >= 0; i-- {
  293. chunk := chunks[i]
  294. if len(chunk) > 2 {
  295. return chunk
  296. }
  297. }
  298. return s.Name
  299. }