From 95a9e4af5bc5906d15ffe574b495db4cbd2a5c06 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 30 Nov 2024 15:33:54 -0500 Subject: [PATCH 1/5] Skip sending Telegram message if bot is not initialized --- internal/notifier/telegram/telegram.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/notifier/telegram/telegram.go b/internal/notifier/telegram/telegram.go index 3f064b6..35afcdd 100644 --- a/internal/notifier/telegram/telegram.go +++ b/internal/notifier/telegram/telegram.go @@ -51,7 +51,12 @@ func (tn *TelegramNotifier) SendChoreReminder(c context.Context, chore *chModel. } func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chModel.Chore, user *uModel.User) { + log := logging.FromContext(c) + if tn == nil { + log.Error("Telegram bot is not initialized, Skipping sending message") + return + } var mt *chModel.NotificationMetadata if err := json.Unmarshal([]byte(*chore.NotificationMetadata), &mt); err != nil { log.Error("Error unmarshalling notification metadata", err) From 850d472445d6196ea76310516b1eef4639dfb940 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 6 Dec 2024 23:43:38 -0500 Subject: [PATCH 2/5] Assign default circle to user when leaving a circle --- internal/circle/handler.go | 1 + internal/circle/repo/repository.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/circle/handler.go b/internal/circle/handler.go index c15d322..26e9172 100644 --- a/internal/circle/handler.go +++ b/internal/circle/handler.go @@ -191,6 +191,7 @@ func handleUserLeavingCircle(h *Handler, c *gin.Context, leavingUser *uModel.Use h.choreRepo.UpdateChores(c, userAssignedCircleChores) h.choreRepo.RemoveChoreAssigneeByCircleID(c, leavingUser.ID, leavingUser.CircleID) + h.circleRepo.AssignDefaultCircle(c, leavingUser.ID) return nil } diff --git a/internal/circle/repo/repository.go b/internal/circle/repo/repository.go index 712cc99..6dfe893 100644 --- a/internal/circle/repo/repository.go +++ b/internal/circle/repo/repository.go @@ -4,6 +4,7 @@ import ( "context" cModel "donetick.com/core/internal/circle/model" + uModel "donetick.com/core/internal/user/model" "gorm.io/gorm" ) @@ -115,3 +116,20 @@ func (r *CircleRepository) GetCircleAdmins(c context.Context, circleID int) ([]* } return circleAdmins, nil } + +func (r *CircleRepository) GetDefaultCircle(c context.Context, userID int) (*cModel.Circle, error) { + var circle cModel.Circle + if err := r.db.WithContext(c).Raw("SELECT circles.* FROM circles LEFT JOIN user_circles on circles.id = user_circles.circle_id WHERE user_circles.user_id = ? AND user_circles.role = 'admin'", userID).Scan(&circle).Error; err != nil { + return nil, err + } + return &circle, nil +} + +func (r *CircleRepository) AssignDefaultCircle(c context.Context, userID int) error { + defaultCircle, err := r.GetDefaultCircle(c, userID) + if err != nil { + return err + } + + return r.db.WithContext(c).Model(&uModel.User{}).Where("id = ?", userID).Update("circle_id", defaultCircle.ID).Error +} From adf5c0c0cd5390d03416e83f4b88049b6862e1db Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 14 Dec 2024 02:15:51 -0500 Subject: [PATCH 3/5] - Assign default circle to user when leaving a circle - Support Pushover - Support Disable Signup - Migrate chatID to TargetID --- config/config.go | 31 ++++++--- config/local.yaml | 3 + config/selfhosted.yaml | 3 + go.mod | 1 + go.sum | 2 + internal/chore/handler.go | 16 +++-- internal/circle/model/model.go | 13 ++-- internal/circle/repo/repository.go | 9 ++- internal/notifier/model/model.go | 40 ++++++++--- internal/notifier/notifier.go | 41 ++++++++++++ internal/notifier/repo/repository.go | 4 ++ internal/notifier/scheduler.go | 26 ++++++-- internal/notifier/service/planner.go | 48 +++++++++----- .../notifier/service/pushover/pushover.go | 37 +++++++++++ .../{ => service}/telegram/telegram.go | 66 ++----------------- internal/user/handler.go | 53 +++++++++++---- internal/user/model/model.go | 32 ++++----- internal/user/repo/repository.go | 21 ++++-- main.go | 5 +- ..._migrate_chat_id_to_notification_target.go | 62 +++++++++++++++++ 20 files changed, 362 insertions(+), 151 deletions(-) create mode 100644 internal/notifier/notifier.go create mode 100644 internal/notifier/service/pushover/pushover.go rename internal/notifier/{ => service}/telegram/telegram.go (55%) create mode 100644 migrations/20241212_migrate_chat_id_to_notification_target.go diff --git a/config/config.go b/config/config.go index 52ed1db..175650f 100644 --- a/config/config.go +++ b/config/config.go @@ -9,21 +9,27 @@ import ( ) type Config struct { - Name string `mapstructure:"name" yaml:"name"` - Telegram TelegramConfig `mapstructure:"telegram" yaml:"telegram"` - Database DatabaseConfig `mapstructure:"database" yaml:"database"` - Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"` - Server ServerConfig `mapstructure:"server" yaml:"server"` - SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"` - EmailConfig EmailConfig `mapstructure:"email" yaml:"email"` - StripeConfig StripeConfig `mapstructure:"stripe" yaml:"stripe"` - IsDoneTickDotCom bool `mapstructure:"is_done_tick_dot_com" yaml:"is_done_tick_dot_com"` + Name string `mapstructure:"name" yaml:"name"` + Telegram TelegramConfig `mapstructure:"telegram" yaml:"telegram"` + Pushover PushoverConfig `mapstructure:"pushover" yaml:"pushover"` + Database DatabaseConfig `mapstructure:"database" yaml:"database"` + Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"` + Server ServerConfig `mapstructure:"server" yaml:"server"` + SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"` + EmailConfig EmailConfig `mapstructure:"email" yaml:"email"` + StripeConfig StripeConfig `mapstructure:"stripe" yaml:"stripe"` + IsDoneTickDotCom bool `mapstructure:"is_done_tick_dot_com" yaml:"is_done_tick_dot_com"` + IsUserCreationDisabled bool `mapstructure:"is_user_creation_disabled" yaml:"is_user_creation_disabled"` } type TelegramConfig struct { Token string `mapstructure:"token" yaml:"token"` } +type PushoverConfig struct { + Token string `mapstructure:"token" yaml:"token"` +} + type DatabaseConfig struct { Type string `mapstructure:"type" yaml:"type"` Host string `mapstructure:"host" yaml:"host"` @@ -98,6 +104,13 @@ func configEnvironmentOverrides(Config *Config) { if os.Getenv("DONETICK_TELEGRAM_TOKEN") != "" { Config.Telegram.Token = os.Getenv("DONETICK_TELEGRAM_TOKEN") } + if os.Getenv("DONETICK_PUSHOVER_TOKEN") != "" { + Config.Pushover.Token = os.Getenv("DONETICK_PUSHOVER_TOKEN") + } + if os.Getenv("DONETICK_DISABLE_SIGNUP") == "true" { + Config.IsUserCreationDisabled = true + } + } func LoadConfig() *Config { // set the config name based on the environment: diff --git a/config/local.yaml b/config/local.yaml index 33e5a3d..7018f24 100644 --- a/config/local.yaml +++ b/config/local.yaml @@ -1,7 +1,10 @@ name: "local" is_done_tick_dot_com: false +is_user_creation_disabled: false telegram: token: "" +pushover: + token: "" database: type: "sqlite" migration: true diff --git a/config/selfhosted.yaml b/config/selfhosted.yaml index 9561055..348ef4f 100644 --- a/config/selfhosted.yaml +++ b/config/selfhosted.yaml @@ -1,7 +1,10 @@ name: "selhosted" is_done_tick_dot_com: false +is_user_creation_disabled: false telegram: token: "" +pushover: + token: "" database: type: "sqlite" migration: true diff --git a/go.mod b/go.mod index ff6902a..ce42755 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.4 // indirect + github.com/gregdel/pushover v1.3.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect diff --git a/go.sum b/go.sum index ede5ec8..92d35cc 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= +github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilfo= +github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/chore/handler.go b/internal/chore/handler.go index 2a646f5..2a62a95 100644 --- a/internal/chore/handler.go +++ b/internal/chore/handler.go @@ -16,9 +16,9 @@ import ( chRepo "donetick.com/core/internal/chore/repo" cRepo "donetick.com/core/internal/circle/repo" lRepo "donetick.com/core/internal/label/repo" + "donetick.com/core/internal/notifier" nRepo "donetick.com/core/internal/notifier/repo" nps "donetick.com/core/internal/notifier/service" - telegram "donetick.com/core/internal/notifier/telegram" tRepo "donetick.com/core/internal/thing/repo" uModel "donetick.com/core/internal/user/model" "donetick.com/core/logging" @@ -57,14 +57,14 @@ type ChoreReq struct { type Handler struct { choreRepo *chRepo.ChoreRepository circleRepo *cRepo.CircleRepository - notifier *telegram.TelegramNotifier + notifier *notifier.Notifier nPlanner *nps.NotificationPlanner nRepo *nRepo.NotificationRepository tRepo *tRepo.ThingRepository lRepo *lRepo.LabelRepository } -func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *telegram.TelegramNotifier, +func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *notifier.Notifier, np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository, lRepo *lRepo.LabelRepository) *Handler { return &Handler{ choreRepo: cr, @@ -928,10 +928,12 @@ func (h *Handler) completeChore(c *gin.Context) { }) return } - go func() { - h.notifier.SendChoreCompletion(c, chore, currentUser) - h.nPlanner.GenerateNotifications(c, updatedChore) - }() + // go func() { + + // h.notifier.SendChoreCompletion(c, chore, currentUser) + // }() + h.nPlanner.GenerateNotifications(c, updatedChore) + c.JSON(200, gin.H{ "res": updatedChore, }) diff --git a/internal/circle/model/model.go b/internal/circle/model/model.go index bf26b34..af1c2c6 100644 --- a/internal/circle/model/model.go +++ b/internal/circle/model/model.go @@ -1,6 +1,10 @@ package circle -import "time" +import ( + "time" + + nModel "donetick.com/core/internal/notifier/model" +) type Circle struct { ID int `json:"id" gorm:"primary_key"` // Unique identifier @@ -29,7 +33,8 @@ type UserCircle struct { type UserCircleDetail struct { UserCircle - Username string `json:"username" gorm:"column:username"` - DisplayName string `json:"displayName" gorm:"column:display_name"` - ChatID int `json:"chatID" gorm:"column:chat_id"` + Username string `json:"username" gorm:"column:username"` + DisplayName string `json:"displayName" gorm:"column:display_name"` + NotificationType nModel.NotificationType `json:"-" gorm:"column:notification_type"` + TargetID string `json:"-" gorm:"column:target_id"` // Target ID } diff --git a/internal/circle/repo/repository.go b/internal/circle/repo/repository.go index 6dfe893..05a1654 100644 --- a/internal/circle/repo/repository.go +++ b/internal/circle/repo/repository.go @@ -41,8 +41,13 @@ func (r *CircleRepository) AddUserToCircle(c context.Context, circleUser *cModel func (r *CircleRepository) GetCircleUsers(c context.Context, circleID int) ([]*cModel.UserCircleDetail, error) { var circleUsers []*cModel.UserCircleDetail - // join user table to get user details like username and display name: - if err := r.db.WithContext(c).Raw("SELECT * FROM user_circles LEFT JOIN users on users.id = user_circles.user_id WHERE user_circles.circle_id = ?", circleID).Scan(&circleUsers).Error; err != nil { + if err := r.db.WithContext(c). + Table("user_circles uc"). + Select("uc.*, u.username, u.display_name, u.chat_id, unt.user_id as user_id, unt.target_id as target_id, unt.type as notification_type"). + Joins("left join users u on u.id = uc.user_id"). + Joins("left join user_notification_targets unt on unt.user_id = u.id"). + Where("uc.circle_id = ?", circleID). + Scan(&circleUsers).Error; err != nil { return nil, err } return circleUsers, nil diff --git a/internal/notifier/model/model.go b/internal/notifier/model/model.go index 47c81df..823807d 100644 --- a/internal/notifier/model/model.go +++ b/internal/notifier/model/model.go @@ -3,13 +3,35 @@ package model import "time" type Notification struct { - ID int `json:"id" gorm:"primaryKey"` - ChoreID int `json:"chore_id" gorm:"column:chore_id"` - UserID int `json:"user_id" gorm:"column:user_id"` - TargetID string `json:"target_id" gorm:"column:target_id"` - Text string `json:"text" gorm:"column:text"` - IsSent bool `json:"is_sent" gorm:"column:is_sent;index;default:false"` - TypeID int `json:"type" gorm:"column:type"` - ScheduledFor time.Time `json:"scheduled_for" gorm:"column:scheduled_for;index"` - CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` + ID int `json:"id" gorm:"primaryKey"` + ChoreID int `json:"chore_id" gorm:"column:chore_id"` + UserID int `json:"user_id" gorm:"column:user_id"` + TargetID string `json:"target_id" gorm:"column:target_id"` + Text string `json:"text" gorm:"column:text"` + IsSent bool `json:"is_sent" gorm:"column:is_sent;index;default:false"` + TypeID NotificationType `json:"type" gorm:"column:type"` + ScheduledFor time.Time `json:"scheduled_for" gorm:"column:scheduled_for;index"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` } + +func (n *Notification) IsValid() bool { + switch n.TypeID { + case NotificationTypeTelegram, NotificationTypePushover: + if n.TargetID == "" { + return false + } else if n.Text == "0" { + return false + } + return true + default: + return false + } +} + +type NotificationType int8 + +const ( + NotificationTypeNone NotificationType = iota + NotificationTypeTelegram + NotificationTypePushover +) diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go new file mode 100644 index 0000000..0e12a0e --- /dev/null +++ b/internal/notifier/notifier.go @@ -0,0 +1,41 @@ +package notifier + +import ( + "context" + + nModel "donetick.com/core/internal/notifier/model" + pushover "donetick.com/core/internal/notifier/service/pushover" + telegram "donetick.com/core/internal/notifier/service/telegram" + "donetick.com/core/logging" +) + +type Notifier struct { + Telegram *telegram.TelegramNotifier + Pushover *pushover.Pushover +} + +func NewNotifier(t *telegram.TelegramNotifier, p *pushover.Pushover) *Notifier { + return &Notifier{ + Telegram: t, + Pushover: p, + } +} + +func (n *Notifier) SendNotification(c context.Context, notification *nModel.Notification) error { + log := logging.FromContext(c) + switch notification.TypeID { + case nModel.NotificationTypeTelegram: + if n.Telegram == nil { + log.Error("Telegram bot is not initialized, Skipping sending message") + return nil + } + return n.Telegram.SendNotification(c, notification) + case nModel.NotificationTypePushover: + if n.Pushover == nil { + log.Error("Pushover is not initialized, Skipping sending message") + return nil + } + return n.Pushover.SendNotification(c, notification) + } + return nil +} diff --git a/internal/notifier/repo/repository.go b/internal/notifier/repo/repository.go index 38d1819..0e69a3f 100644 --- a/internal/notifier/repo/repository.go +++ b/internal/notifier/repo/repository.go @@ -41,3 +41,7 @@ func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookb } return notifications, nil } + +func (r *NotificationRepository) DeleteSentNotifications(c context.Context, since time.Time) error { + return r.db.WithContext(c).Where("is_sent = ? AND scheduled_for < ?", true, since).Delete(&nModel.Notification{}).Error +} diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go index 69470d2..1baa2ac 100644 --- a/internal/notifier/scheduler.go +++ b/internal/notifier/scheduler.go @@ -8,7 +8,6 @@ import ( "donetick.com/core/config" chRepo "donetick.com/core/internal/chore/repo" nRepo "donetick.com/core/internal/notifier/repo" - notifier "donetick.com/core/internal/notifier/telegram" uRepo "donetick.com/core/internal/user/repo" "donetick.com/core/logging" ) @@ -23,12 +22,12 @@ type Scheduler struct { choreRepo *chRepo.ChoreRepository userRepo *uRepo.UserRepository stopChan chan bool - notifier *notifier.TelegramNotifier + notifier *Notifier notificationRepo *nRepo.NotificationRepository SchedulerJobs config.SchedulerConfig } -func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *notifier.TelegramNotifier, nr *nRepo.NotificationRepository) *Scheduler { +func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *Notifier, nr *nRepo.NotificationRepository) *Scheduler { return &Scheduler{ choreRepo: cr, userRepo: ur, @@ -43,12 +42,23 @@ func (s *Scheduler) Start(c context.Context) { log := logging.FromContext(c) log.Debug("Scheduler started") go s.runScheduler(c, " NOTIFICATION_SCHEDULER ", s.loadAndSendNotificationJob, 3*time.Minute) + go s.runScheduler(c, " NOTIFICATION_CLEANUP ", s.cleanupSentNotifications, 24*time.Hour*30) +} +func (s *Scheduler) cleanupSentNotifications(c context.Context) (time.Duration, error) { + log := logging.FromContext(c) + deleteBefore := time.Now().UTC().Add(-time.Hour * 24 * 30) + err := s.notificationRepo.DeleteSentNotifications(c, deleteBefore) + if err != nil { + log.Error("Error deleting sent notifications", err) + return time.Duration(0), err + } + return time.Duration(0), nil } func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration, error) { log := logging.FromContext(c) startTime := time.Now() - getAllPendingNotifications, err := s.notificationRepo.GetPendingNotificaiton(c, time.Minute*15) + getAllPendingNotifications, err := s.notificationRepo.GetPendingNotificaiton(c, time.Minute*900) log.Debug("Getting pending notifications", " count ", len(getAllPendingNotifications)) if err != nil { @@ -57,7 +67,11 @@ func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration } for _, notification := range getAllPendingNotifications { - s.notifier.SendNotification(c, notification) + err := s.notifier.SendNotification(c, notification) + if err != nil { + log.Error("Error sending notification", err) + continue + } notification.IsSent = true } @@ -78,7 +92,7 @@ func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c c if err != nil { logging.FromContext(c).Error("Error running scheduler job", err) } - logging.FromContext(c).Debug("Scheduler job completed", jobName, " time", elapsedTime.String()) + logging.FromContext(c).Debug("Scheduler job completed", jobName, " time: ", elapsedTime.String()) } time.Sleep(interval) } diff --git a/internal/notifier/service/planner.go b/internal/notifier/service/planner.go index 2f7f207..a8f8ef6 100644 --- a/internal/notifier/service/planner.go +++ b/internal/notifier/service/planner.go @@ -80,18 +80,19 @@ func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDe } } for _, user := range users { - notification := &nModel.Notification{ ChoreID: chore.ID, IsSent: false, ScheduledFor: *chore.NextDueDate, CreatedAt: time.Now().UTC(), - TypeID: 1, + TypeID: user.NotificationType, UserID: user.ID, - TargetID: fmt.Sprint(user.ChatID), + TargetID: user.TargetID, Text: fmt.Sprintf("📅 Reminder: *%s* is due today and assigned to %s.", chore.Name, assignee.DisplayName), } - notifications = append(notifications, notification) + if notification.IsValid() { + notifications = append(notifications, notification) + } } return notifications @@ -112,12 +113,14 @@ func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircl IsSent: false, ScheduledFor: *chore.NextDueDate, CreatedAt: time.Now().UTC().Add(-time.Hour * 3), - TypeID: 3, + TypeID: user.NotificationType, UserID: user.ID, - TargetID: fmt.Sprint(user.ChatID), + TargetID: user.TargetID, Text: fmt.Sprintf("📢 Heads up! *%s* is due soon (on %s) and assigned to %s.", chore.Name, chore.NextDueDate.Format("January 2nd"), assignee.DisplayName), } - notifications = append(notifications, notification) + if notification.IsValid() { + notifications = append(notifications, notification) + } } return notifications @@ -141,12 +144,14 @@ func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCirc IsSent: false, ScheduledFor: scheduleTime, CreatedAt: time.Now().UTC(), - TypeID: 2, + TypeID: user.NotificationType, UserID: user.ID, - TargetID: fmt.Sprint(user.ChatID), + TargetID: fmt.Sprint(user.TargetID), Text: fmt.Sprintf("🚨 *%s* is now %d hours overdue. Please complete it as soon as possible. (Assigned to %s)", chore.Name, hours, assignee.DisplayName), } - notifications = append(notifications, notification) + if notification.IsValid() { + notifications = append(notifications, notification) + } } } @@ -160,7 +165,7 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica return notifications } if mt.DueDate { - notifications = append(notifications, &nModel.Notification{ + notification := &nModel.Notification{ ChoreID: chore.ID, IsSent: false, ScheduledFor: *chore.NextDueDate, @@ -168,10 +173,14 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica TypeID: 1, TargetID: fmt.Sprint(*mt.CircleGroupID), Text: fmt.Sprintf("📅 Reminder: *%s* is due today.", chore.Name), - }) + } + if notification.IsValid() { + notifications = append(notifications, notification) + } + } if mt.PreDue { - notifications = append(notifications, &nModel.Notification{ + notification := &nModel.Notification{ ChoreID: chore.ID, IsSent: false, ScheduledFor: *chore.NextDueDate, @@ -179,12 +188,16 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica TypeID: 3, TargetID: fmt.Sprint(*mt.CircleGroupID), Text: fmt.Sprintf("📢 Heads up! *%s* is due soon (on %s).", chore.Name, chore.NextDueDate.Format("January 2nd")), - }) + } + if notification.IsValid() { + notifications = append(notifications, notification) + } + } if mt.Nagging { for _, hours := range []int{24, 48, 72} { scheduleTime := chore.NextDueDate.Add(time.Hour * time.Duration(hours)) - notifications = append(notifications, &nModel.Notification{ + notification := &nModel.Notification{ ChoreID: chore.ID, IsSent: false, ScheduledFor: scheduleTime, @@ -192,7 +205,10 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica TypeID: 2, TargetID: fmt.Sprint(*mt.CircleGroupID), Text: fmt.Sprintf("🚨 *%s* is now %d hours overdue. Please complete it as soon as possible.", chore.Name, hours), - }) + } + if notification.IsValid() { + notifications = append(notifications, notification) + } } } diff --git a/internal/notifier/service/pushover/pushover.go b/internal/notifier/service/pushover/pushover.go new file mode 100644 index 0000000..08caacd --- /dev/null +++ b/internal/notifier/service/pushover/pushover.go @@ -0,0 +1,37 @@ +package pushover + +import ( + "context" + + "donetick.com/core/config" + nModel "donetick.com/core/internal/notifier/model" + "donetick.com/core/logging" + "github.com/gregdel/pushover" +) + +type Pushover struct { + pushover *pushover.Pushover +} + +func NewPushover(cfg *config.Config) *Pushover { + + pushoverApp := pushover.New(cfg.Pushover.Token) + + return &Pushover{ + pushover: pushoverApp, + } +} + +func (p *Pushover) SendNotification(c context.Context, notification *nModel.Notification) error { + log := logging.FromContext(c) + recipient := pushover.NewRecipient(notification.TargetID) + message := pushover.NewMessageWithTitle(notification.Text, "Donetick") + + _, err := p.pushover.SendMessage(message, recipient) + if err != nil { + log.Debug("Error sending pushover notification", err) + return err + } + + return nil +} diff --git a/internal/notifier/telegram/telegram.go b/internal/notifier/service/telegram/telegram.go similarity index 55% rename from internal/notifier/telegram/telegram.go rename to internal/notifier/service/telegram/telegram.go index 35afcdd..82ed539 100644 --- a/internal/notifier/telegram/telegram.go +++ b/internal/notifier/service/telegram/telegram.go @@ -3,6 +3,7 @@ package telegram import ( "context" "encoding/json" + "errors" "fmt" "strconv" @@ -30,26 +31,6 @@ func NewTelegramNotifier(config *config.Config) *TelegramNotifier { } } -func (tn *TelegramNotifier) SendChoreReminder(c context.Context, chore *chModel.Chore, users []*uModel.User) { - for _, user := range users { - var assignee *uModel.User - if user.ID == chore.AssignedTo { - if user.ChatID == 0 { - continue - } - assignee = user - text := fmt.Sprintf("*%s* is due today and assigned to *%s*", chore.Name, assignee.DisplayName) - msg := tgbotapi.NewMessage(user.ChatID, text) - msg.ParseMode = "Markdown" - _, err := tn.bot.Send(msg) - if err != nil { - fmt.Println("Error sending message to user: ", err) - } - break - } - } -} - func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chModel.Chore, user *uModel.User) { log := logging.FromContext(c) @@ -89,54 +70,17 @@ func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chMode } -func (tn *TelegramNotifier) SendChoreOverdue(c context.Context, chore *chModel.Chore, users []*uModel.User) { - log := logging.FromContext(c) - for _, user := range users { - if user.ChatID == 0 { - continue - } - text := fmt.Sprintf("*%s* is overdue and assigned to *%s*", chore.Name, user.DisplayName) - msg := tgbotapi.NewMessage(user.ChatID, text) - msg.ParseMode = "Markdown" - _, err := tn.bot.Send(msg) - if err != nil { - log.Error("Error sending message to user: ", err) - log.Debug("Error sending message, chore: ", chore.Name, " user: ", user.DisplayName, " chatID: ", user.ChatID, " user id: ", user.ID) - } - } -} - -func (tn *TelegramNotifier) SendChorePreDue(c context.Context, chore *chModel.Chore, users []*uModel.User) { - log := logging.FromContext(c) - for _, user := range users { - if user.ID != chore.AssignedTo { - continue - } - if user.ChatID == 0 { - continue - } - text := fmt.Sprintf("*%s* is due tomorrow and assigned to *%s*", chore.Name, user.DisplayName) - msg := tgbotapi.NewMessage(user.ChatID, text) - msg.ParseMode = "Markdown" - _, err := tn.bot.Send(msg) - if err != nil { - log.Error("Error sending message to user: ", err) - log.Debug("Error sending message, chore: ", chore.Name, " user: ", user.DisplayName, " chatID: ", user.ChatID, " user id: ", user.ID) - } - } -} - -func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.Notification) { +func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.Notification) error { log := logging.FromContext(c) if notification.TargetID == "" { log.Error("Notification target ID is empty") - return + return errors.New("Notification target ID is empty") } chatID, err := strconv.ParseInt(notification.TargetID, 10, 64) if err != nil { log.Error("Error parsing chatID: ", err) - return + return err } msg := tgbotapi.NewMessage(chatID, notification.Text) @@ -145,5 +89,7 @@ func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nM if err != nil { log.Error("Error sending message to user: ", err) log.Debug("Error sending message, notification: ", notification.Text, " chatID: ", chatID) + return err } + return nil } diff --git a/internal/user/handler.go b/internal/user/handler.go index 845ecd8..9d861f9 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -14,6 +14,7 @@ import ( cModel "donetick.com/core/internal/circle/model" cRepo "donetick.com/core/internal/circle/repo" "donetick.com/core/internal/email" + nModel "donetick.com/core/internal/notifier/model" uModel "donetick.com/core/internal/user/model" uRepo "donetick.com/core/internal/user/repo" "donetick.com/core/internal/utils" @@ -26,20 +27,22 @@ import ( ) type Handler struct { - userRepo *uRepo.UserRepository - circleRepo *cRepo.CircleRepository - jwtAuth *jwt.GinJWTMiddleware - email *email.EmailSender - isDonetickDotCom bool + userRepo *uRepo.UserRepository + circleRepo *cRepo.CircleRepository + jwtAuth *jwt.GinJWTMiddleware + email *email.EmailSender + isDonetickDotCom bool + IsUserCreationDisabled bool } func NewHandler(ur *uRepo.UserRepository, cr *cRepo.CircleRepository, jwtAuth *jwt.GinJWTMiddleware, email *email.EmailSender, config *config.Config) *Handler { return &Handler{ - userRepo: ur, - circleRepo: cr, - jwtAuth: jwtAuth, - email: email, - isDonetickDotCom: config.IsDoneTickDotCom, + userRepo: ur, + circleRepo: cr, + jwtAuth: jwtAuth, + email: email, + isDonetickDotCom: config.IsDoneTickDotCom, + IsUserCreationDisabled: config.IsUserCreationDisabled, } } @@ -68,6 +71,12 @@ func (h *Handler) GetAllUsers() gin.HandlerFunc { } func (h *Handler) signUp(c *gin.Context) { + if h.IsUserCreationDisabled { + c.JSON(403, gin.H{ + "error": "User creation is disabled", + }) + return + } type SignUpReq struct { Username string `json:"username" binding:"required,min=4,max=20"` @@ -497,8 +506,8 @@ func (h *Handler) UpdateNotificationTarget(c *gin.Context) { } type Request struct { - Type uModel.UserNotificationType `json:"type" binding:"required"` - Token string `json:"token" binding:"required"` + Type nModel.NotificationType `json:"type"` + Target string `json:"target" binding:"required"` } var req Request @@ -506,13 +515,28 @@ func (h *Handler) UpdateNotificationTarget(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } + if req.Type == nModel.NotificationTypeNone { + err := h.userRepo.DeleteNotificationTarget(c, currentUser.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification target"}) + return + } + c.JSON(http.StatusOK, gin.H{}) + return + } - err := h.userRepo.UpdateNotificationTarget(c, currentUser.ID, req.Token, req.Type) + err := h.userRepo.UpdateNotificationTarget(c, currentUser.ID, req.Target, req.Type) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification target"}) return } + err = h.userRepo.UpdateNotificationTargetForAllNotifications(c, currentUser.ID, req.Target, req.Type) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification target for all notifications"}) + return + } + c.JSON(http.StatusOK, gin.H{}) } @@ -579,11 +603,12 @@ func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware, limiter authRoutes := router.Group("auth") authRoutes.Use(utils.RateLimitMiddleware(limiter)) { + authRoutes.POST("/:provider/callback", h.thirdPartyAuthCallback) authRoutes.POST("/", h.signUp) authRoutes.POST("login", auth.LoginHandler) authRoutes.GET("refresh", auth.RefreshHandler) - authRoutes.POST("/:provider/callback", h.thirdPartyAuthCallback) authRoutes.POST("reset", h.resetPassword) authRoutes.POST("password", h.updateUserPassword) } + } diff --git a/internal/user/model/model.go b/internal/user/model/model.go index 4cfb38b..6a4c434 100644 --- a/internal/user/model/model.go +++ b/internal/user/model/model.go @@ -1,6 +1,10 @@ package user -import "time" +import ( + "time" + + nModel "donetick.com/core/internal/notifier/model" +) type User struct { ID int `json:"id" gorm:"primary_key"` // Unique identifier @@ -16,10 +20,10 @@ type User struct { UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` // Updated at Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled // Email string `json:"email" gorm:"column:email"` // Email - CustomerID *string `gorm:"column:customer_id;<-:false"` // read only column - Subscription *string `json:"subscription" gorm:"column:subscription;<-:false"` // read only column - Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read only column - UserNotificationTargets []UserNotificationTarget `json:"-" gorm:"foreignKey:UserID;references:ID"` + CustomerID *string `gorm:"column:customer_id;<-:false"` // read only column + Subscription *string `json:"subscription" gorm:"column:subscription;<-:false"` // read only column + Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read only column + UserNotificationTargets UserNotificationTarget `json:"notification_target" gorm:"foreignKey:UserID;references:ID"` } type UserPasswordReset struct { @@ -39,18 +43,8 @@ type APIToken struct { } type UserNotificationTarget struct { - ID int `json:"id" gorm:"primary_key"` // Unique identifier - UserID int `json:"userId" gorm:"column:user_id;index"` // Index on userID - Type UserNotificationType `json:"type" gorm:"column:type"` // Type - TargetID string `json:"targetId" gorm:"column:target_id"` // Target ID - CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` + UserID int `json:"userId" gorm:"column:user_id;index;primaryKey"` // Index on userID + Type nModel.NotificationType `json:"type" gorm:"column:type"` // Type + TargetID string `json:"target_id" gorm:"column:target_id"` // Target ID + CreatedAt time.Time `json:"-" gorm:"column:created_at"` } - -type UserNotificationType int8 - -const ( - _ UserNotificationType = iota - Android - IOS - Telegram -) diff --git a/internal/user/repo/repository.go b/internal/user/repo/repository.go index 7cf0972..38fdb32 100644 --- a/internal/user/repo/repository.go +++ b/internal/user/repo/repository.go @@ -6,6 +6,7 @@ import ( "time" "donetick.com/core/config" + nModel "donetick.com/core/internal/notifier/model" uModel "donetick.com/core/internal/user/model" "donetick.com/core/logging" "gorm.io/gorm" @@ -54,11 +55,11 @@ func (r *UserRepository) CreateUser(c context.Context, user *uModel.User) (*uMod func (r *UserRepository) GetUserByUsername(c context.Context, username string) (*uModel.User, error) { var user *uModel.User if r.isDonetickDotCom { - if err := r.db.WithContext(c).Table("users u").Select("u.*, ss.status as subscription, ss.expired_at as expiration").Joins("left join stripe_customers sc on sc.user_id = u.id ").Joins("left join stripe_subscriptions ss on sc.customer_id = ss.customer_id").Where("username = ?", username).First(&user).Error; err != nil { + if err := r.db.WithContext(c).Preload("UserNotificationTargets").Table("users u").Select("u.*, ss.status as subscription, ss.expired_at as expiration").Joins("left join stripe_customers sc on sc.user_id = u.id ").Joins("left join stripe_subscriptions ss on sc.customer_id = ss.customer_id").Where("username = ?", username).First(&user).Error; err != nil { return nil, err } } else { - if err := r.db.WithContext(c).Table("users u").Select("u.*, 'active' as subscription, '2999-12-31' as expiration").Where("username = ?", username).First(&user).Error; err != nil { + if err := r.db.WithContext(c).Preload("UserNotificationTargets").Table("users u").Select("u.*, 'active' as subscription, '2999-12-31' as expiration").Where("username = ?", username).First(&user).Error; err != nil { return nil, err } } @@ -159,10 +160,22 @@ func (r *UserRepository) DeleteAPIToken(c context.Context, userID int, tokenID s return r.db.WithContext(c).Where("id = ? AND user_id = ?", tokenID, userID).Delete(&uModel.APIToken{}).Error } -func (r *UserRepository) UpdateNotificationTarget(c context.Context, userID int, targetID string, targetType uModel.UserNotificationType) error { - return r.db.WithContext(c).Model(&uModel.UserNotificationTarget{}).Where("user_id = ? AND type = ?", userID, targetType).Update("target_id", targetID).Error +func (r *UserRepository) UpdateNotificationTarget(c context.Context, userID int, targetID string, targetType nModel.NotificationType) error { + return r.db.WithContext(c).Save(&uModel.UserNotificationTarget{ + UserID: userID, + TargetID: targetID, + Type: targetType, + CreatedAt: time.Now().UTC(), + }).Error } +func (r *UserRepository) DeleteNotificationTarget(c context.Context, userID int) error { + return r.db.WithContext(c).Where("user_id = ?", userID).Delete(&uModel.UserNotificationTarget{}).Error +} + +func (r *UserRepository) UpdateNotificationTargetForAllNotifications(c context.Context, userID int, targetID string, targetType nModel.NotificationType) error { + return r.db.WithContext(c).Model(&nModel.Notification{}).Where("user_id = ?", userID).Update("target_id", targetID).Update("type", targetType).Error +} func (r *UserRepository) UpdatePasswordByUserId(c context.Context, userID int, password string) error { return r.db.WithContext(c).Model(&uModel.User{}).Where("id = ?", userID).Update("password", password).Error } diff --git a/main.go b/main.go index f128cba..afa98fd 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,8 @@ import ( notifier "donetick.com/core/internal/notifier" nRepo "donetick.com/core/internal/notifier/repo" nps "donetick.com/core/internal/notifier/service" - telegram "donetick.com/core/internal/notifier/telegram" + "donetick.com/core/internal/notifier/service/pushover" + telegram "donetick.com/core/internal/notifier/service/telegram" "donetick.com/core/internal/thing" tRepo "donetick.com/core/internal/thing/repo" "donetick.com/core/internal/user" @@ -64,7 +65,9 @@ func main() { fx.Provide(nps.NewNotificationPlanner), // add notifier + fx.Provide(pushover.NewPushover), fx.Provide(telegram.NewTelegramNotifier), + fx.Provide(notifier.NewNotifier), // Rate limiter fx.Provide(utils.NewRateLimiter), diff --git a/migrations/20241212_migrate_chat_id_to_notification_target.go b/migrations/20241212_migrate_chat_id_to_notification_target.go new file mode 100644 index 0000000..cf9d3c3 --- /dev/null +++ b/migrations/20241212_migrate_chat_id_to_notification_target.go @@ -0,0 +1,62 @@ +package migrations + +import ( + "context" + "fmt" + + nModel "donetick.com/core/internal/notifier/model" + uModel "donetick.com/core/internal/user/model" + "donetick.com/core/logging" + "gorm.io/gorm" +) + +type MigrateChatIdToNotificationTarget20241212 struct{} + +func (m MigrateChatIdToNotificationTarget20241212) ID() string { + return "20241212_migrate_chat_id_to_notification_target" +} + +func (m MigrateChatIdToNotificationTarget20241212) Description() string { + return `Migrate Chat ID to notification target to support multiple notification targets and platform other than telegram` +} + +func (m MigrateChatIdToNotificationTarget20241212) Down(ctx context.Context, db *gorm.DB) error { + return nil +} + +func (m MigrateChatIdToNotificationTarget20241212) Up(ctx context.Context, db *gorm.DB) error { + log := logging.FromContext(ctx) + + // Start a transaction + return db.Transaction(func(tx *gorm.DB) error { + // Get All Users + var users []uModel.User + if err := tx.Table("users").Find(&users).Error; err != nil { + log.Errorf("Failed to fetch users: %v", err) + } + + var notificationTargets []uModel.UserNotificationTarget + for _, user := range users { + if user.ChatID == 0 { + continue + } + notificationTargets = append(notificationTargets, uModel.UserNotificationTarget{ + UserID: user.ID, + TargetID: fmt.Sprint(user.ChatID), + Type: nModel.NotificationTypeTelegram, + }) + } + + // Insert all notification targets + if err := tx.Table("user_notification_targets").Create(¬ificationTargets).Error; err != nil { + log.Errorf("Failed to insert notification targets: %v", err) + } + + return nil + }) +} + +// Register this migration +func init() { + Register(MigrateChatIdToNotificationTarget20241212{}) +} From bb739de594800d4cfa45116f3d72367cd36737c3 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 14 Dec 2024 02:53:51 -0500 Subject: [PATCH 4/5] Recreate UserNotificationTarget from scratch --- .../20241212_migrate_chat_id_to_notification_target.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/migrations/20241212_migrate_chat_id_to_notification_target.go b/migrations/20241212_migrate_chat_id_to_notification_target.go index cf9d3c3..f95b74d 100644 --- a/migrations/20241212_migrate_chat_id_to_notification_target.go +++ b/migrations/20241212_migrate_chat_id_to_notification_target.go @@ -26,6 +26,15 @@ func (m MigrateChatIdToNotificationTarget20241212) Down(ctx context.Context, db func (m MigrateChatIdToNotificationTarget20241212) Up(ctx context.Context, db *gorm.DB) error { log := logging.FromContext(ctx) + // if UserNotificationTarget table already exists drop it and recreate it: + if err := db.Migrator().DropTable(&uModel.UserNotificationTarget{}); err != nil { + log.Errorf("Failed to drop user_notification_targets table: %v", err) + } + + // Create UserNotificationTarget table + if err := db.AutoMigrate(&uModel.UserNotificationTarget{}); err != nil { + log.Errorf("Failed to create user_notification_targets table: %v", err) + } // Start a transaction return db.Transaction(func(tx *gorm.DB) error { From 7f7293ac0ead17ee1af4659b5199a9f0fe1d76cd Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 14 Dec 2024 02:54:25 -0500 Subject: [PATCH 5/5] remove username field from UserCircleDetail struct --- internal/circle/model/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/circle/model/model.go b/internal/circle/model/model.go index af1c2c6..e731f1e 100644 --- a/internal/circle/model/model.go +++ b/internal/circle/model/model.go @@ -33,7 +33,7 @@ type UserCircle struct { type UserCircleDetail struct { UserCircle - Username string `json:"username" gorm:"column:username"` + Username string `json:"-" gorm:"column:username"` DisplayName string `json:"displayName" gorm:"column:display_name"` NotificationType nModel.NotificationType `json:"-" gorm:"column:notification_type"` TargetID string `json:"-" gorm:"column:target_id"` // Target ID