aboutsummaryrefslogtreecommitdiffstats
path: root/internal/notifier
diff options
context:
space:
mode:
Diffstat (limited to 'internal/notifier')
-rw-r--r--internal/notifier/model/model.go15
-rw-r--r--internal/notifier/repo/repository.go43
-rw-r--r--internal/notifier/scheduler.go89
-rw-r--r--internal/notifier/service/planner.go149
-rw-r--r--internal/notifier/telegram/telegram.go127
5 files changed, 423 insertions, 0 deletions
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(&notifications).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(&notifications).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)
+ }
+}