From c13dd9addbf89f716e4ef5cfdf1d673139ffcb68 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 30 Jun 2024 21:41:41 -0400 Subject: Move to Donetick Org, first commit --- internal/notifier/model/model.go | 15 ++++ internal/notifier/repo/repository.go | 43 ++++++++++ internal/notifier/scheduler.go | 89 ++++++++++++++++++++ internal/notifier/service/planner.go | 149 +++++++++++++++++++++++++++++++++ internal/notifier/telegram/telegram.go | 127 ++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 internal/notifier/model/model.go create mode 100644 internal/notifier/repo/repository.go create mode 100644 internal/notifier/scheduler.go create mode 100644 internal/notifier/service/planner.go create mode 100644 internal/notifier/telegram/telegram.go (limited to 'internal/notifier') diff --git a/internal/notifier/model/model.go b/internal/notifier/model/model.go new file mode 100644 index 0000000..47c81df --- /dev/null +++ b/internal/notifier/model/model.go @@ -0,0 +1,15 @@ +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"` +} diff --git a/internal/notifier/repo/repository.go b/internal/notifier/repo/repository.go new file mode 100644 index 0000000..576a3f0 --- /dev/null +++ b/internal/notifier/repo/repository.go @@ -0,0 +1,43 @@ +package user + +import ( + "context" + "time" + + nModel "donetick.com/core/internal/notifier/model" + "gorm.io/gorm" +) + +type NotificationRepository struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) *NotificationRepository { + return &NotificationRepository{db} +} + +func (r *NotificationRepository) DeleteAllChoreNotifications(choreID int) error { + return r.db.Where("chore_id = ?", choreID).Delete(&nModel.Notification{}).Error +} + +func (r *NotificationRepository) BatchInsertNotifications(notifications []*nModel.Notification) error { + return r.db.Create(¬ifications).Error +} +func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.Notification) error { + // Extract IDs from notifications + var ids []int + for _, notification := range notifications { + ids = append(ids, notification.ID) + } + // Use the extracted IDs in the Where clause + return r.db.Model(&nModel.Notification{}).Where("id IN (?)", ids).Update("is_sent", true).Error +} +func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookback time.Duration) ([]*nModel.Notification, error) { + var notifications []*nModel.Notification + start := time.Now().UTC().Add(-lookback) + end := time.Now().UTC() + if err := r.db.Debug().Where("is_sent = ? AND scheduled_for < ? AND scheduled_for > ?", false, end, start).Find(¬ifications).Error; err != nil { + return nil, err + } + return notifications, nil +} diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go new file mode 100644 index 0000000..69470d2 --- /dev/null +++ b/internal/notifier/scheduler.go @@ -0,0 +1,89 @@ +package notifier + +import ( + "context" + "log" + "time" + + "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" +) + +type keyType string + +const ( + SchedulerKey keyType = "scheduler" +) + +type Scheduler struct { + choreRepo *chRepo.ChoreRepository + userRepo *uRepo.UserRepository + stopChan chan bool + notifier *notifier.TelegramNotifier + notificationRepo *nRepo.NotificationRepository + SchedulerJobs config.SchedulerConfig +} + +func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *notifier.TelegramNotifier, nr *nRepo.NotificationRepository) *Scheduler { + return &Scheduler{ + choreRepo: cr, + userRepo: ur, + stopChan: make(chan bool), + notifier: n, + notificationRepo: nr, + SchedulerJobs: cfg.SchedulerJobs, + } +} + +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) +} + +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) + log.Debug("Getting pending notifications", " count ", len(getAllPendingNotifications)) + + if err != nil { + log.Error("Error getting pending notifications") + return time.Since(startTime), err + } + + for _, notification := range getAllPendingNotifications { + s.notifier.SendNotification(c, notification) + notification.IsSent = true + } + + s.notificationRepo.MarkNotificationsAsSent(getAllPendingNotifications) + return time.Since(startTime), nil +} +func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c context.Context) (time.Duration, error), interval time.Duration) { + + for { + logging.FromContext(c).Debug("Scheduler running ", jobName, " time", time.Now().String()) + + select { + case <-s.stopChan: + log.Println("Scheduler stopped") + return + default: + elapsedTime, err := job(c) + if err != nil { + logging.FromContext(c).Error("Error running scheduler job", err) + } + logging.FromContext(c).Debug("Scheduler job completed", jobName, " time", elapsedTime.String()) + } + time.Sleep(interval) + } +} + +func (s *Scheduler) Stop() { + s.stopChan <- true +} diff --git a/internal/notifier/service/planner.go b/internal/notifier/service/planner.go new file mode 100644 index 0000000..22502ab --- /dev/null +++ b/internal/notifier/service/planner.go @@ -0,0 +1,149 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "time" + + chModel "donetick.com/core/internal/chore/model" + cModel "donetick.com/core/internal/circle/model" + cRepo "donetick.com/core/internal/circle/repo" + nModel "donetick.com/core/internal/notifier/model" + nRepo "donetick.com/core/internal/notifier/repo" + "donetick.com/core/logging" +) + +type NotificationPlanner struct { + nRepo *nRepo.NotificationRepository + cRepo *cRepo.CircleRepository +} + +func NewNotificationPlanner(nr *nRepo.NotificationRepository, cr *cRepo.CircleRepository) *NotificationPlanner { + return &NotificationPlanner{nRepo: nr, + cRepo: cr, + } +} + +func (n *NotificationPlanner) GenerateNotifications(c context.Context, chore *chModel.Chore) bool { + log := logging.FromContext(c) + circleMembers, err := n.cRepo.GetCircleUsers(c, chore.CircleID) + assignees := make([]*cModel.UserCircleDetail, 0) + for _, member := range circleMembers { + if member.ID == chore.AssignedTo { + assignees = append(assignees, member) + } + } + + if err != nil { + log.Error("Error getting circle members", err) + return false + } + n.nRepo.DeleteAllChoreNotifications(chore.ID) + notifications := make([]*nModel.Notification, 0) + if !chore.Notification || chore.FrequencyType == "trigger" { + + return true + } + var mt *chModel.NotificationMetadata + if err := json.Unmarshal([]byte(*chore.NotificationMetadata), &mt); err != nil { + log.Error("Error unmarshalling notification metadata", err) + return true + } + if mt.DueDate { + notifications = append(notifications, generateDueNotifications(chore, assignees)...) + } + if mt.PreDue { + notifications = append(notifications, generatePreDueNotifications(chore, assignees)...) + } + if mt.Nagging { + notifications = append(notifications, generateOverdueNotifications(chore, assignees)...) + } + + n.nRepo.BatchInsertNotifications(notifications) + return true +} + +func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification { + var assignee *cModel.UserCircleDetail + notifications := make([]*nModel.Notification, 0) + for _, user := range users { + if user.ID == chore.AssignedTo { + assignee = user + break + } + } + for _, user := range users { + + notification := &nModel.Notification{ + ChoreID: chore.ID, + IsSent: false, + ScheduledFor: *chore.NextDueDate, + CreatedAt: time.Now().UTC(), + TypeID: 1, + UserID: user.ID, + TargetID: fmt.Sprint(user.ChatID), + Text: fmt.Sprintf("📅 Reminder: '%s' is due today and assigned to %s.", chore.Name, assignee.DisplayName), + } + notifications = append(notifications, notification) + } + + return notifications +} + +func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification { + var assignee *cModel.UserCircleDetail + for _, user := range users { + if user.ID == chore.AssignedTo { + assignee = user + break + } + } + notifications := make([]*nModel.Notification, 0) + for _, user := range users { + notification := &nModel.Notification{ + ChoreID: chore.ID, + IsSent: false, + ScheduledFor: *chore.NextDueDate, + CreatedAt: time.Now().UTC().Add(-time.Hour * 3), + TypeID: 3, + UserID: user.ID, + TargetID: fmt.Sprint(user.ChatID), + Text: fmt.Sprintf("📢 Heads up! Chore '%s' is due soon (on %s) and assigned to %s.", chore.Name, chore.NextDueDate.Format("January 2nd"), assignee.DisplayName), + } + notifications = append(notifications, notification) + + } + return notifications + +} + +func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification { + var assignee *cModel.UserCircleDetail + for _, user := range users { + if user.ID == chore.AssignedTo { + assignee = user + break + } + } + notifications := make([]*nModel.Notification, 0) + for _, hours := range []int{24, 48, 72} { + scheduleTime := chore.NextDueDate.Add(time.Hour * time.Duration(hours)) + for _, user := range users { + notification := &nModel.Notification{ + ChoreID: chore.ID, + IsSent: false, + ScheduledFor: scheduleTime, + CreatedAt: time.Now().UTC(), + TypeID: 2, + UserID: user.ID, + TargetID: fmt.Sprint(user.ChatID), + 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) + } + } + + return notifications + +} diff --git a/internal/notifier/telegram/telegram.go b/internal/notifier/telegram/telegram.go new file mode 100644 index 0000000..e35f0c8 --- /dev/null +++ b/internal/notifier/telegram/telegram.go @@ -0,0 +1,127 @@ +package telegram + +import ( + "context" + "fmt" + "strconv" + + "donetick.com/core/config" + chModel "donetick.com/core/internal/chore/model" + nModel "donetick.com/core/internal/notifier/model" + uModel "donetick.com/core/internal/user/model" + "donetick.com/core/logging" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +type TelegramNotifier struct { + bot *tgbotapi.BotAPI +} + +func NewTelegramNotifier(config *config.Config) *TelegramNotifier { + bot, err := tgbotapi.NewBotAPI(config.Telegram.Token) + if err != nil { + fmt.Println("Error creating bot: ", err) + return nil + } + + return &TelegramNotifier{ + bot: bot, + } +} + +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, users []*uModel.User) { + log := logging.FromContext(c) + for _, user := range users { + if user.ChatID == 0 { + continue + } + text := fmt.Sprintf("🎉 '%s' is completed! is off the list, %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) 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) { + + log := logging.FromContext(c) + if notification.TargetID == "" { + log.Error("Notification target ID is empty") + return + } + chatID, err := strconv.ParseInt(notification.TargetID, 10, 64) + if err != nil { + log.Error("Error parsing chatID: ", err) + return + } + + msg := tgbotapi.NewMessage(chatID, notification.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, notification: ", notification.Text, " chatID: ", chatID) + } +} -- cgit