diff --git a/config/config.go b/config/config.go index 616b8e3..df56dd9 100644 --- a/config/config.go +++ b/config/config.go @@ -33,6 +33,7 @@ const ( KeyLatestTotalTime = "latest_total_time" KeyLatestTotalUsers = "latest_total_users" KeyLastImportImport = "last_import" + KeyFirstHeartbeat = "first_heartbeat" KeyNewsbox = "newsbox" SimpleDateFormat = "2006-01-02" diff --git a/main.go b/main.go index 468bd8d..d6132ac 100644 --- a/main.go +++ b/main.go @@ -183,14 +183,14 @@ func main() { reportService = services.NewReportService(summaryService, userService, mailService) diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository) housekeepingService = services.NewHousekeepingService(userService, heartbeatService, summaryService) - miscService = services.NewMiscService(userService, summaryService, keyValueService) + miscService = services.NewMiscService(userService, heartbeatService, summaryService, keyValueService) // Schedule background tasks go aggregationService.Schedule() go leaderboardService.Schedule() go reportService.Schedule() go housekeepingService.Schedule() - go miscService.ScheduleCountTotalTime() + go miscService.Schedule() routes.Init() diff --git a/models/view/settings.go b/models/view/settings.go index d0430b2..3388f4c 100644 --- a/models/view/settings.go +++ b/models/view/settings.go @@ -1,6 +1,9 @@ package view -import "github.com/muety/wakapi/models" +import ( + "github.com/muety/wakapi/models" + "time" +) type SettingsViewModel struct { User *models.User @@ -10,6 +13,7 @@ type SettingsViewModel struct { Projects []string SubscriptionPrice string DataRetentionMonths int + UserFirstData time.Time SupportContact string ApiKey string Success string diff --git a/repositories/key_value.go b/repositories/key_value.go index 3e2ae31..c06440e 100644 --- a/repositories/key_value.go +++ b/repositories/key_value.go @@ -34,6 +34,17 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro return kv, nil } +func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) { + var keyValues []*models.KeyStringValue + if err := r.db.Table("key_string_values"). + Where("`key` like ?", like). + Find(&keyValues). + Error; err != nil { + return nil, err + } + return keyValues, nil +} + func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error { result := r.db. Clauses(clause.OnConflict{ diff --git a/repositories/repositories.go b/repositories/repositories.go index 31ffbd9..8b36eb7 100644 --- a/repositories/repositories.go +++ b/repositories/repositories.go @@ -43,6 +43,7 @@ type IKeyValueRepository interface { GetString(string) (*models.KeyStringValue, error) PutString(*models.KeyStringValue) error DeleteString(string) error + Search(string) ([]*models.KeyStringValue, error) } type ILanguageMappingRepository interface { diff --git a/routes/settings.go b/routes/settings.go index 82c8ef1..74e2c37 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -739,6 +739,13 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode subscriptionPrice = h.config.Subscriptions.StandardPrice } + // user first data + var firstData time.Time + firstDataKv := h.keyValueSrvc.MustGetString(fmt.Sprintf("%s_%s", conf.KeyFirstHeartbeat, user.ID)) + if firstDataKv.Value != "" { + firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value) + } + return &view.SettingsViewModel{ User: user, LanguageMappings: mappings, @@ -746,6 +753,7 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode Labels: combinedLabels, Projects: projects, ApiKey: user.ApiKey, + UserFirstData: firstData, SubscriptionPrice: subscriptionPrice, SupportContact: h.config.App.SupportContact, DataRetentionMonths: h.config.App.DataRetentionMonths, diff --git a/services/key_value.go b/services/key_value.go index deef6bb..99fc039 100644 --- a/services/key_value.go +++ b/services/key_value.go @@ -22,6 +22,10 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error return srv.repository.GetString(key) } +func (srv *KeyValueService) GetByPrefix(prefix string) ([]*models.KeyStringValue, error) { + return srv.repository.Search(prefix + "%") +} + func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue { kv, err := srv.repository.GetString(key) if err != nil { diff --git a/services/misc.go b/services/misc.go index e148a11..6060014 100644 --- a/services/misc.go +++ b/services/misc.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "github.com/emvi/logbuch" "github.com/muety/artifex/v2" "github.com/muety/wakapi/config" @@ -14,36 +15,57 @@ import ( ) const ( - countUsersEvery = 1 * time.Hour + countUsersEvery = 1 * time.Hour + computeOldestDataEvery = 6 * time.Hour ) var countLock = sync.Mutex{} +var firstDataLock = sync.Mutex{} type MiscService struct { - config *config.Config - userService IUserService - summaryService ISummaryService - keyValueService IKeyValueService - queueDefault *artifex.Dispatcher - queueWorkers *artifex.Dispatcher + config *config.Config + userService IUserService + heartbeatService IHeartbeatService + summaryService ISummaryService + keyValueService IKeyValueService + queueDefault *artifex.Dispatcher + queueWorkers *artifex.Dispatcher } -func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService { +func NewMiscService(userService IUserService, heartbeatService IHeartbeatService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService { return &MiscService{ - config: config.Get(), - userService: userService, - summaryService: summaryService, - keyValueService: keyValueService, - queueDefault: config.GetDefaultQueue(), - queueWorkers: config.GetQueue(config.QueueProcessing), + config: config.Get(), + userService: userService, + heartbeatService: heartbeatService, + summaryService: summaryService, + keyValueService: keyValueService, + queueDefault: config.GetDefaultQueue(), + queueWorkers: config.GetQueue(config.QueueProcessing), } } -func (srv *MiscService) ScheduleCountTotalTime() { +func (srv *MiscService) Schedule() { logbuch.Info("scheduling total time counting") if _, err := srv.queueDefault.DispatchEvery(srv.CountTotalTime, countUsersEvery); err != nil { config.Log().Error("failed to schedule user counting jobs, %v", err) } + + logbuch.Info("scheduling first data computing") + if _, err := srv.queueDefault.DispatchEvery(srv.ComputeOldestHeartbeats, computeOldestDataEvery); err != nil { + config.Log().Error("failed to schedule first data computing jobs, %v", err) + } + + // run once initially for a fresh instance + if !srv.existsUsersTotalTime() { + if err := srv.queueDefault.Dispatch(srv.CountTotalTime); err != nil { + config.Log().Error("failed to dispatch user counting jobs, %v", err) + } + } + if !srv.existsUsersFirstData() { + if err := srv.queueDefault.Dispatch(srv.ComputeOldestHeartbeats); err != nil { + config.Log().Error("failed to dispatch first data computing jobs, %v", err) + } + } } func (srv *MiscService) CountTotalTime() { @@ -95,6 +117,39 @@ func (srv *MiscService) CountTotalTime() { }(&pendingJobs) } +func (srv *MiscService) ComputeOldestHeartbeats() { + logbuch.Info("computing users' first data") + + if err := srv.queueWorkers.Dispatch(func() { + if ok := firstDataLock.TryLock(); !ok { + config.Log().Warn("couldn't acquire lock for computing users' first data, job is still pending") + return + } + defer firstDataLock.Unlock() + + results, err := srv.heartbeatService.GetFirstByUsers() + if err != nil { + config.Log().Error("failed to compute users' first data, %v", err) + } + + for _, entry := range results { + if entry.Time.T().IsZero() { + continue + } + + kvKey := fmt.Sprintf("%s_%s", config.KeyFirstHeartbeat, entry.User) + if err := srv.keyValueService.PutString(&models.KeyStringValue{ + Key: kvKey, + Value: entry.Time.T().Format(time.RFC822Z), + }); err != nil { + config.Log().Error("failed to save user's first heartbeat time: %v", err) + } + } + }); err != nil { + config.Log().Error("failed to enqueue computing first data for user, %v", err) + } +} + 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 { @@ -103,3 +158,13 @@ func (srv *MiscService) countUserTotalTime(userId string) time.Duration { } return result.TotalTime() } + +func (srv *MiscService) existsUsersTotalTime() bool { + results, _ := srv.keyValueService.GetByPrefix(config.KeyLatestTotalTime) + return len(results) > 0 +} + +func (srv *MiscService) existsUsersFirstData() bool { + results, _ := srv.keyValueService.GetByPrefix(config.KeyFirstHeartbeat) + return len(results) > 0 +} diff --git a/services/services.go b/services/services.go index dc4a653..d1d6908 100644 --- a/services/services.go +++ b/services/services.go @@ -12,7 +12,7 @@ type IAggregationService interface { } type IMiscService interface { - ScheduleCountTotalTime() + Schedule() CountTotalTime() } @@ -52,6 +52,7 @@ type IDiagnosticsService interface { type IKeyValueService interface { GetString(string) (*models.KeyStringValue, error) MustGetString(string) *models.KeyStringValue + GetByPrefix(string) ([]*models.KeyStringValue, error) PutString(*models.KeyStringValue) error DeleteString(string) error } diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 8c09024..6f3102b 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -701,10 +701,12 @@
{{ end }} + {{ if not .UserFirstData.IsZero }} - Your currently oldest data point is from 2022-01-01. + Your currently oldest data point is from {{ .UserFirstData | datetime }}.
+ {{ end }} Subscription status: