From 50c54685ecf541ae9994f34bb446463370c56a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Thu, 29 Dec 2022 17:12:34 +0100 Subject: [PATCH] feat: subscription expiry notification mails --- config.default.yml | 17 ++-- config/config.go | 23 +++-- config/jobqueue.go | 2 + main.go | 4 +- models/user.go | 16 ++++ routes/subscription.go | 32 +++++-- services/mail/mail.go | 28 ++++++ services/mail/types.go | 6 ++ services/misc.go | 105 +++++++++++++++++++++- services/services.go | 1 + views/mail/subscription_expiring.tpl.html | 62 +++++++++++++ 11 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 views/mail/subscription_expiring.tpl.html diff --git a/config.default.yml b/config.default.yml index f2ae238..a6f159c 100644 --- a/config.default.yml +++ b/config.default.yml @@ -1,4 +1,6 @@ env: production +quick_start: false # whether to skip initial tasks on application startup, like summary generation +skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes server: listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 @@ -44,7 +46,7 @@ db: charset: utf8mb4 # only used for mysql connections max_conn: 2 # maximum number of concurrent connections to maintain ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) - automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up + automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up security: password_salt: # change this @@ -60,6 +62,14 @@ sentry: sample_rate: 0.75 # probability of tracing a request sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request +# only relevant for running wakapi as a hosted service with paid subscriptions and stripe payments +subscriptions: + enabled: false + stripe_api_key: + stripe_secret_key: + stripe_endpoint_secret: + standard_price_id: + mail: enabled: true # whether to enable mails (used for password resets, reports, etc.) provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] @@ -77,7 +87,4 @@ mail: mailwhale: url: client_id: - client_secret: - -quick_start: false # whether to skip initial tasks on application startup, like summary generation -skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes \ No newline at end of file + client_secret: \ No newline at end of file diff --git a/config/config.go b/config/config.go index 5ab8e91..07a78b6 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "flag" "fmt" + "github.com/robfig/cron/v3" "io/ioutil" "net/http" "regexp" @@ -11,13 +12,11 @@ import ( "strings" "time" - "github.com/muety/wakapi/utils" - "github.com/robfig/cron/v3" - "github.com/emvi/logbuch" "github.com/gorilla/securecookie" "github.com/jinzhu/configor" "github.com/muety/wakapi/data" + "github.com/muety/wakapi/utils" uuid "github.com/satori/go.uuid" ) @@ -28,11 +27,12 @@ const ( SQLDialectPostgres = "postgres" SQLDialectSqlite = "sqlite3" - KeyLatestTotalTime = "latest_total_time" - KeyLatestTotalUsers = "latest_total_users" - KeyLastImportImport = "last_import" - KeyFirstHeartbeat = "first_heartbeat" - KeyNewsbox = "newsbox" + KeyLatestTotalTime = "latest_total_time" + KeyLatestTotalUsers = "latest_total_users" + KeyLastImportImport = "last_import" + KeyFirstHeartbeat = "first_heartbeat" + KeySubscriptionNotificationSent = "sub_reminder" + KeyNewsbox = "newsbox" SimpleDateFormat = "2006-01-02" SimpleDateTimeFormat = "2006-01-02 15:04:05" @@ -124,6 +124,7 @@ type serverConfig struct { type subscriptionsConfig struct { Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"` + ExpiryNotifications bool `yaml:"expiry_notifications" default:"true" env:"WAKAPI_SUBSCRIPTIONS_EXPIRY_NOTIFICATIONS"` StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"` StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"` StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"` @@ -412,7 +413,11 @@ func Load(version string) *Config { if config.App.DataRetentionMonths <= 0 { logbuch.Info("disabling data retention policy, keeping data forever") } else { - logbuch.Info("data retention policy set to keep data for %d months at max", config.App.DataRetentionMonths) + dataRetentionWarning := fmt.Sprintf("⚠️ data retention policy will cause user data older than %d months to be deleted", config.App.DataRetentionMonths) + if config.Subscriptions.Enabled { + dataRetentionWarning += " (except for users with active subscriptions)" + } + logbuch.Warn(dataRetentionWarning) } // some validation checks diff --git a/config/jobqueue.go b/config/jobqueue.go index 55ae422..1fdb69f 100644 --- a/config/jobqueue.go +++ b/config/jobqueue.go @@ -15,6 +15,7 @@ const ( QueueDefault = "wakapi.default" QueueProcessing = "wakapi.processing" QueueReports = "wakapi.reports" + QueueMails = "wakapi.mail" QueueImports = "wakapi.imports" QueueHousekeeping = "wakapi.housekeeping" ) @@ -31,6 +32,7 @@ func init() { InitQueue(QueueDefault, 1) InitQueue(QueueProcessing, halfCPUs()) InitQueue(QueueReports, 1) + InitQueue(QueueMails, 1) InitQueue(QueueImports, 1) InitQueue(QueueHousekeeping, halfCPUs()) } diff --git a/main.go b/main.go index d6132ac..0a566ec 100644 --- a/main.go +++ b/main.go @@ -183,7 +183,7 @@ func main() { reportService = services.NewReportService(summaryService, userService, mailService) diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository) housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService) - miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService) + miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService, mailService) // Schedule background tasks go aggregationService.Schedule() @@ -216,7 +216,7 @@ func main() { // MVC Handlers summaryHandler := routes.NewSummaryHandler(summaryService, userService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService) - subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService) + subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService, keyValueService) leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService) homeHandler := routes.NewHomeHandler(keyValueService) loginHandler := routes.NewLoginHandler(userService, mailService) diff --git a/models/user.go b/models/user.go index d8d2045..8f0d8e3 100644 --- a/models/user.go +++ b/models/user.go @@ -135,6 +135,22 @@ func (u *User) HasActiveSubscriptionStrict() bool { return u.SubscribedUntil != nil && u.SubscribedUntil.T().After(time.Now()) } +// SubscriptionExpiredSince returns if a user's subscription has expiration and the duration since when that happened. +// Returns (false, ), if subscription hasn't expired, yet. +// Returns (false, 0), if subscriptions are not enabled in the first place. +// Returns (true, ), if the user has never had a subscription. +func (u *User) SubscriptionExpiredSince() (bool, time.Duration) { + cfg := conf.Get() + if !cfg.Subscriptions.Enabled { + return false, 0 + } + if u.SubscribedUntil == nil { + return true, 99 * 365 * 24 * time.Hour + } + diff := time.Now().Sub(u.SubscribedUntil.T()) + return diff >= 0, diff +} + func (u *User) MinDataAge() time.Time { retentionMonths := conf.Get().App.DataRetentionMonths if retentionMonths <= 0 || u.HasActiveSubscription() { diff --git a/routes/subscription.go b/routes/subscription.go index 4ef41b5..8c29944 100644 --- a/routes/subscription.go +++ b/routes/subscription.go @@ -23,15 +23,17 @@ import ( ) type SubscriptionHandler struct { - config *conf.Config - userSrvc services.IUserService - mailSrvc services.IMailService - httpClient *http.Client + config *conf.Config + userSrvc services.IUserService + mailSrvc services.IMailService + keyValueSrvc services.IKeyValueService + httpClient *http.Client } func NewSubscriptionHandler( userService services.IUserService, mailService services.IMailService, + keyValueService services.IKeyValueService, ) *SubscriptionHandler { config := conf.Get() @@ -48,10 +50,11 @@ func NewSubscriptionHandler( } return &SubscriptionHandler{ - config: config, - userSrvc: userService, - mailSrvc: mailService, - httpClient: &http.Client{Timeout: 10 * time.Second}, + config: config, + userSrvc: userService, + mailSrvc: mailService, + keyValueSrvc: keyValueService, + httpClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -204,11 +207,14 @@ func (h *SubscriptionHandler) GetCheckoutCancel(w http.ResponseWriter, r *http.R } func (h *SubscriptionHandler) handleSubscriptionEvent(subscription *stripe.Subscription, user *models.User) error { + var hasSubscribed bool + switch subscription.Status { case "active": until := models.CustomTime(time.Unix(subscription.CurrentPeriodEnd, 0)) if user.SubscribedUntil == nil || !user.SubscribedUntil.T().Equal(until.T()) { + hasSubscribed = true user.SubscribedUntil = &until logbuch.Info("user %s got active subscription %s until %v", user.ID, subscription.ID, user.SubscribedUntil) } @@ -224,6 +230,9 @@ func (h *SubscriptionHandler) handleSubscriptionEvent(subscription *stripe.Subsc } _, err := h.userSrvc.Update(user) + if err == nil && hasSubscribed { + go h.clearSubscriptionNotificationStatus(user.ID) + } return err } @@ -265,3 +274,10 @@ func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.C return nil, errors.New("no customer found with given criteria") } } + +func (h *SubscriptionHandler) clearSubscriptionNotificationStatus(userId string) { + key := fmt.Sprintf("%s_%s", conf.KeySubscriptionNotificationSent, userId) + if err := h.keyValueSrvc.DeleteString(key); err != nil { + conf.Log().Error("failed to delete '%s', %v", key, err) + } +} diff --git a/services/mail/mail.go b/services/mail/mail.go index 33c917c..bd7a5d7 100644 --- a/services/mail/mail.go +++ b/services/mail/mail.go @@ -19,10 +19,12 @@ const ( tplNameImportNotification = "import_finished" tplNameWakatimeFailureNotification = "wakatime_connection_failure" tplNameReport = "report" + tplNameSubscriptionNotification = "subscription_expiring" subjectPasswordReset = "Wakapi - Password Reset" subjectImportNotification = "Wakapi - Data Import Finished" subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure" subjectReport = "Wakapi - Report from %s" + subjectSubscriptionNotification = "Wakapi - Subscription expiring / expired" ) type SendingService interface { @@ -122,6 +124,24 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report) return m.sendingService.Send(mail) } +func (m *MailService) SendSubscriptionNotification(recipient *models.User, hasExpired bool) error { + tpl, err := m.getSubscriptionNotificationTemplate(SubscriptionNotificationTplData{ + PublicUrl: m.config.Server.PublicUrl, + DataRetentionMonths: m.config.App.DataRetentionMonths, + HasExpired: hasExpired, + }) + if err != nil { + return err + } + mail := &models.Mail{ + From: models.MailAddress(m.config.Mail.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectSubscriptionNotification, + } + mail.WithHTML(tpl.String()) + return m.sendingService.Send(mail) +} + func (m *MailService) getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) { var rendered bytes.Buffer if err := m.templates[m.fmtName(tplNamePasswordReset)].Execute(&rendered, data); err != nil { @@ -154,6 +174,14 @@ func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, erro return &rendered, nil } +func (m *MailService) getSubscriptionNotificationTemplate(data SubscriptionNotificationTplData) (*bytes.Buffer, error) { + var rendered bytes.Buffer + if err := m.templates[m.fmtName(tplNameSubscriptionNotification)].Execute(&rendered, data); err != nil { + return nil, err + } + return &rendered, nil +} + func (m *MailService) fmtName(name string) string { return fmt.Sprintf("%s.tpl.html", name) } diff --git a/services/mail/types.go b/services/mail/types.go index 3b4b321..9f95c78 100644 --- a/services/mail/types.go +++ b/services/mail/types.go @@ -20,3 +20,9 @@ type WakatimeFailureNotificationNotificationTplData struct { type ReportTplData struct { Report *models.Report } + +type SubscriptionNotificationTplData struct { + PublicUrl string + HasExpired bool + DataRetentionMonths int +} diff --git a/services/misc.go b/services/misc.go index 6060014..e454f0b 100644 --- a/services/misc.go +++ b/services/misc.go @@ -2,12 +2,14 @@ package services import ( "fmt" + "github.com/duke-git/lancet/v2/slice" "github.com/emvi/logbuch" "github.com/muety/artifex/v2" "github.com/muety/wakapi/config" "github.com/muety/wakapi/utils" "go.uber.org/atomic" "strconv" + "strings" "sync" "time" @@ -15,8 +17,13 @@ import ( ) const ( - countUsersEvery = 1 * time.Hour - computeOldestDataEvery = 6 * time.Hour + countUsersEvery = 1 * time.Hour + computeOldestDataEvery = 6 * time.Hour + notifyExpiringSubscriptionsEvery = 12 * time.Hour +) + +const ( + notifyBeforeSubscriptionExpiry = 7 * 24 * time.Hour ) var countLock = sync.Mutex{} @@ -28,19 +35,23 @@ type MiscService struct { heartbeatService IHeartbeatService summaryService ISummaryService keyValueService IKeyValueService + mailService IMailService queueDefault *artifex.Dispatcher queueWorkers *artifex.Dispatcher + queueMails *artifex.Dispatcher } -func NewMiscService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService { +func NewMiscService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService, keyValueService IKeyValueService, mailService IMailService) *MiscService { return &MiscService{ config: config.Get(), userService: userService, heartbeatService: heartbeatService, summaryService: summaryService, keyValueService: keyValueService, + mailService: mailService, queueDefault: config.GetDefaultQueue(), queueWorkers: config.GetQueue(config.QueueProcessing), + queueMails: config.GetQueue(config.QueueMails), } } @@ -55,6 +66,13 @@ func (srv *MiscService) Schedule() { config.Log().Error("failed to schedule first data computing jobs, %v", err) } + if srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 { + logbuch.Info("scheduling subscription notifications") + if _, err := srv.queueDefault.DispatchEvery(srv.ComputeOldestHeartbeats, notifyExpiringSubscriptionsEvery); err != nil { + config.Log().Error("failed to schedule subscription notification jobs, %v", err) + } + } + // run once initially for a fresh instance if !srv.existsUsersTotalTime() { if err := srv.queueDefault.Dispatch(srv.CountTotalTime); err != nil { @@ -66,6 +84,11 @@ func (srv *MiscService) Schedule() { config.Log().Error("failed to dispatch first data computing jobs, %v", err) } } + if !srv.existsSubscriptionNotifications() && srv.config.Subscriptions.Enabled && srv.config.Subscriptions.ExpiryNotifications && srv.config.App.DataRetentionMonths > 0 { + if err := srv.queueDefault.Dispatch(srv.NotifyExpiringSubscription); err != nil { + config.Log().Error("failed to schedule subscription notification jobs, %v", err) + } + } } func (srv *MiscService) CountTotalTime() { @@ -78,6 +101,7 @@ func (srv *MiscService) CountTotalTime() { users, err := srv.userService.GetAll() if err != nil { config.Log().Error("failed to fetch users for time counting, %v", err) + return } var totalTime = atomic.NewDuration(0) @@ -130,6 +154,7 @@ func (srv *MiscService) ComputeOldestHeartbeats() { results, err := srv.heartbeatService.GetFirstByUsers() if err != nil { config.Log().Error("failed to compute users' first data, %v", err) + return } for _, entry := range results { @@ -150,6 +175,55 @@ func (srv *MiscService) ComputeOldestHeartbeats() { } } +// NotifyExpiringSubscription sends a reminder e-mail to all users, notifying them if their subscription has expired or is about to, given these conditions: +// - Data cleanup is enabled on the server (non-zero retention time) +// - Subscriptions are enabled on the server (aka. users can do something about their old data getting cleaned up) +// - User has an e-mail address configured +// - User's subscription has expired or is about to expire soon +// - The user has gotten no such e-mail before recently +// Note: only one mail will be sent for either "expired" or "about to expire" state. +func (srv *MiscService) NotifyExpiringSubscription() { + if srv.config.App.DataRetentionMonths <= 0 || !srv.config.Subscriptions.Enabled { + return + } + + logbuch.Info("notifying users about soon to expire subscriptions") + + users, err := srv.userService.GetAll() + if err != nil { + config.Log().Error("failed to fetch users for subscription notifications, %v", err) + return + } + + var subscriptionReminders map[string][]*models.KeyStringValue + if result, err := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent); err == nil { + subscriptionReminders = slice.GroupWith[*models.KeyStringValue, string](result, func(kv *models.KeyStringValue) string { + return strings.TrimPrefix(kv.Key, config.KeySubscriptionNotificationSent+"_") + }) + } else { + config.Log().Error("failed to fetch key-values for subscription notifications, %v", err) + return + } + + for _, u := range users { + if u.HasActiveSubscription() && u.Email == "" { + config.Log().Warn("invalid state: user '%s' has active subscription but no e-mail address set") + } + + // skip users without e-mail address + // skip users who already received a notification before + // skip users who either never had a subscription before or intentionally deleted it + if _, ok := subscriptionReminders[u.ID]; ok || u.Email == "" || u.SubscribedUntil == nil { + continue + } + + expired, expiredSince := u.SubscriptionExpiredSince() + if expired || (expiredSince < 0 && expiredSince*-1 <= notifyBeforeSubscriptionExpiry) { + srv.sendSubscriptionNotificationScheduled(u, expired) + } + } +} + func (srv *MiscService) countUserTotalTime(userId string) time.Duration { result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: userId}, srv.summaryService.Retrieve, nil, false) if err != nil { @@ -159,6 +233,26 @@ func (srv *MiscService) countUserTotalTime(userId string) time.Duration { return result.TotalTime() } +func (srv *MiscService) sendSubscriptionNotificationScheduled(user *models.User, hasExpired bool) { + u := *user + srv.queueMails.Dispatch(func() { + logbuch.Info("sending subscription expiry notification mail to %s (expired: %v)", u.ID, hasExpired) + defer time.Sleep(10 * time.Second) + + if err := srv.mailService.SendSubscriptionNotification(&u, hasExpired); err != nil { + config.Log().Error("failed to send subscription notification mail to user '%s', %v", u.ID, err) + return + } + + if err := srv.keyValueService.PutString(&models.KeyStringValue{ + Key: fmt.Sprintf("%s_%s", config.KeySubscriptionNotificationSent, u.ID), + Value: time.Now().Format(time.RFC822Z), + }); err != nil { + config.Log().Error("failed to update subscription notification status key-value for user %s, %v", u.ID, err) + } + }) +} + func (srv *MiscService) existsUsersTotalTime() bool { results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime) return len(results) > 0 @@ -168,3 +262,8 @@ func (srv *MiscService) existsUsersFirstData() bool { results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat) return len(results) > 0 } + +func (srv *MiscService) existsSubscriptionNotifications() bool { + results, _ := srv.keyValueService.GetByPrefix(config.KeySubscriptionNotificationSent) + return len(results) > 0 +} diff --git a/services/services.go b/services/services.go index 3135a0d..d56c8f4 100644 --- a/services/services.go +++ b/services/services.go @@ -80,6 +80,7 @@ type IMailService interface { SendWakatimeFailureNotification(*models.User, int) error SendImportNotification(*models.User, time.Duration, int) error SendReport(*models.User, *models.Report) error + SendSubscriptionNotification(*models.User, bool) error } type IDurationService interface { diff --git a/views/mail/subscription_expiring.tpl.html b/views/mail/subscription_expiring.tpl.html new file mode 100644 index 0000000..4424f06 --- /dev/null +++ b/views/mail/subscription_expiring.tpl.html @@ -0,0 +1,62 @@ + + + +{{ template "head.tpl.html" . }} + + + + + + + + +
  + {{ template "theader.tpl.html" . }} + +
+ + + + +
+ + + + +
+ {{ if .HasExpired }} +

Subscription expired

+ {{ else }} +

Subscription about to expire

+ {{ end }} +

+ {{ if .HasExpired }} + Your Wakapi subscription has expired. + {{ else }} + Your Wakapi subscription will expire soon. + {{ end }} + All coding activity older than {{ .DataRetentionMonths }} months will be deleted soon. Please refer to this article for further details on subscriptions. +

+ + + + + + +
+ + + + + + +
Go to settings
+
+
+
+ + {{ template "tfooter.tpl.html" . }} +
+
 
+ +