From 80afab3bc028436020d2fa5b307612b9039d138a Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 10 Feb 2025 01:44:42 -0500 Subject: [PATCH] Support Reminder events Move code to schedule to have event independent from NotificationPlatform --- internal/events/producer.go | 22 ++++++++++---------- internal/notifier/model/model.go | 30 ++++++++++++++++++++++++++-- internal/notifier/notifier.go | 4 ---- internal/notifier/scheduler.go | 10 +++++++++- internal/notifier/service/planner.go | 9 ++++++--- internal/user/handler.go | 8 ++++---- internal/user/repo/repository.go | 6 +++--- 7 files changed, 61 insertions(+), 28 deletions(-) diff --git a/internal/events/producer.go b/internal/events/producer.go index d1158d4..fa46af6 100644 --- a/internal/events/producer.go +++ b/internal/events/producer.go @@ -24,13 +24,13 @@ const ( type EventType string const ( - EventTypeUnknown EventType = "" - EventTypeChoreCreated EventType = "CREATED" - EventTypeChoreReminder EventType = "REMINDER" - EventTypeChoreUpdated EventType = "UPDATED" - EventTypeChoreCompleted EventType = "COMPLETED" - EventTypeChoreReassigned EventType = "REASSIGNED" - EventTypeChoreSkipped EventType = "SKIPPED" + EventTypeUnknown EventType = "" + EventTypeTaskCreated EventType = "task.created" + EventTypeTaskReminder EventType = "task.reminder" + EventTypeTaskUpdated EventType = "task.updated" + EventTypeTaskCompleted EventType = "task.completed" + EventTypeTaskReassigned EventType = "task.reassigned" + EventTypeTaskSkipped EventType = "task.skipped" ) type Event struct { @@ -122,7 +122,7 @@ func (p *EventsProducer) ChoreCompleted(ctx context.Context, webhookURL *string, } event := Event{ - Type: EventTypeChoreCompleted, + Type: EventTypeTaskCompleted, URL: *webhookURL, Timestamp: time.Now(), Data: ChoreData{Chore: chore, @@ -140,7 +140,7 @@ func (p *EventsProducer) ChoreSkipped(ctx context.Context, webhookURL *string, c } event := Event{ - Type: EventTypeChoreSkipped, + Type: EventTypeTaskSkipped, URL: *webhookURL, Timestamp: time.Now(), Data: ChoreData{Chore: chore, @@ -151,13 +151,13 @@ func (p *EventsProducer) ChoreSkipped(ctx context.Context, webhookURL *string, c p.publishEvent(event) } -func (p *EventsProducer) NotificaitonEvent(ctx context.Context, url string, event interface{}) { +func (p *EventsProducer) NotificationEvent(ctx context.Context, url string, event interface{}) { // print the event and the url : p.logger.Debug("Sending notification event") p.publishEvent(Event{ URL: url, - Type: EventTypeChoreReminder, + Type: EventTypeTaskReminder, Timestamp: time.Now(), Data: event, }) diff --git a/internal/notifier/model/model.go b/internal/notifier/model/model.go index cde2c1c..0eb0456 100644 --- a/internal/notifier/model/model.go +++ b/internal/notifier/model/model.go @@ -1,6 +1,11 @@ package model -import "time" +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" +) type Notification struct { ID int `json:"id" gorm:"primaryKey"` @@ -13,7 +18,7 @@ type Notification struct { TypeID NotificationPlatform `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"` - RawEvent interface{} `json:"raw_event" gorm:"column:raw_event;type:jsonb"` + RawEvent JSONB `json:"raw_event" gorm:"column:raw_event;type:jsonb"` } type NotificationDetails struct { Notification @@ -32,3 +37,24 @@ const ( NotificationPlatformTelegram NotificationPlatformPushover ) + +type JSONB map[string]interface{} + +func (j JSONB) Value() (driver.Value, error) { + value, err := json.Marshal(j) + if err != nil { + return nil, err + } + return string(value), nil +} + +func (j *JSONB) Scan(value interface{}) error { + switch v := value.(type) { + case []byte: + return json.Unmarshal(v, j) + case string: + return json.Unmarshal([]byte(v), j) + default: + return errors.New("type assertion to []byte or string failed") + } +} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index 28fb523..0bf1cc3 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -45,10 +45,6 @@ func (n *Notifier) SendNotification(c context.Context, notification *nModel.Noti if err != nil { log.Error("Failed to send notification", "err", err) } - if notification.RawEvent != nil && notification.WebhookURL != nil { - // if we have a webhook url, we should send the event to the webhook - n.eventsProducer.NotificaitonEvent(c, *notification.WebhookURL, notification.RawEvent) - } return nil } diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go index 1baa2ac..a01144d 100644 --- a/internal/notifier/scheduler.go +++ b/internal/notifier/scheduler.go @@ -7,6 +7,7 @@ import ( "donetick.com/core/config" chRepo "donetick.com/core/internal/chore/repo" + "donetick.com/core/internal/events" nRepo "donetick.com/core/internal/notifier/repo" uRepo "donetick.com/core/internal/user/repo" "donetick.com/core/logging" @@ -23,17 +24,19 @@ type Scheduler struct { userRepo *uRepo.UserRepository stopChan chan bool notifier *Notifier + eventsProducer *events.EventsProducer notificationRepo *nRepo.NotificationRepository SchedulerJobs config.SchedulerConfig } -func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *Notifier, nr *nRepo.NotificationRepository) *Scheduler { +func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *Notifier, nr *nRepo.NotificationRepository, ep *events.EventsProducer) *Scheduler { return &Scheduler{ choreRepo: cr, userRepo: ur, stopChan: make(chan bool), notifier: n, notificationRepo: nr, + eventsProducer: ep, SchedulerJobs: cfg.SchedulerJobs, } } @@ -72,6 +75,11 @@ func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration log.Error("Error sending notification", err) continue } + if notification.RawEvent != nil && notification.WebhookURL != nil { + // if we have a webhook url, we should send the event to the webhook + s.eventsProducer.NotificationEvent(c, *notification.WebhookURL, notification.RawEvent) + } + notification.IsSent = true } diff --git a/internal/notifier/service/planner.go b/internal/notifier/service/planner.go index 2544b10..7df1ec1 100644 --- a/internal/notifier/service/planner.go +++ b/internal/notifier/service/planner.go @@ -87,12 +87,13 @@ func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDe CreatedAt: time.Now().UTC(), TypeID: user.NotificationType, UserID: user.ID, + CircleID: user.CircleID, TargetID: user.TargetID, Text: fmt.Sprintf("📅 Reminder: *%s* is due today and assigned to %s.", chore.Name, assignee.DisplayName), RawEvent: map[string]interface{}{ "id": chore.ID, "name": chore.Name, - "due_date": chore.NextDueDate.Format("January 2nd"), + "due_date": chore.NextDueDate, "assignee": assignee.DisplayName, "assignee_username": assignee.Username, }, @@ -122,12 +123,13 @@ func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircl CreatedAt: time.Now().UTC().Add(-time.Hour * 3), TypeID: user.NotificationType, UserID: user.ID, + CircleID: user.CircleID, 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), RawEvent: map[string]interface{}{ "id": chore.ID, "name": chore.Name, - "due_date": chore.NextDueDate.Format("January 2nd"), + "due_date": chore.NextDueDate, "assignee": assignee.DisplayName, "assignee_username": assignee.Username, }, @@ -160,13 +162,14 @@ func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCirc CreatedAt: time.Now().UTC(), TypeID: user.NotificationType, UserID: user.ID, + CircleID: user.CircleID, 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), RawEvent: map[string]interface{}{ "id": chore.ID, "type": EventTypeOverdue, "name": chore.Name, - "due_date": chore.NextDueDate.Format("January 2nd"), + "due_date": chore.NextDueDate, "assignee": assignee.DisplayName, "assignee_username": assignee.Username, }, diff --git a/internal/user/handler.go b/internal/user/handler.go index 1d18c8e..ca83933 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -216,7 +216,7 @@ func (h *Handler) thirdPartyAuthCallback(c *gin.Context) { // create a random password for the user using crypto/rand: password := auth.GenerateRandomPassword(12) encodedPassword, err := auth.EncodePassword(password) - acc = &uModel.User{ + account := &uModel.User{ Username: userinfo.Id, Email: userinfo.Email, Image: userinfo.Picture, @@ -224,7 +224,7 @@ func (h *Handler) thirdPartyAuthCallback(c *gin.Context) { DisplayName: userinfo.GivenName, Provider: uModel.AuthProviderGoogle, } - createdUser, err := h.userRepo.CreateUser(c, acc) + createdUser, err := h.userRepo.CreateUser(c, account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Unable to create user", @@ -316,14 +316,14 @@ func (h *Handler) thirdPartyAuthCallback(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Password encoding failed"}) return } - acc = &uModel.User{ + account := &uModel.User{ Username: claims.Email, Email: claims.Email, Password: encodedPassword, DisplayName: claims.DisplayName, Provider: uModel.AuthProviderOAuth2, } - createdUser, err := h.userRepo.CreateUser(c, acc) + createdUser, err := h.userRepo.CreateUser(c, account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Unable to create user", diff --git a/internal/user/repo/repository.go b/internal/user/repo/repository.go index 9873766..17a97b9 100644 --- a/internal/user/repo/repository.go +++ b/internal/user/repo/repository.go @@ -75,9 +75,9 @@ func (r *UserRepository) UpdateUserCircle(c context.Context, userID, circleID in return r.db.WithContext(c).Model(&uModel.User{}).Where("id = ?", userID).Update("circle_id", circleID).Error } -func (r *UserRepository) FindByEmail(c context.Context, email string) (*uModel.User, error) { - var user *uModel.User - if err := r.db.WithContext(c).Where("email = ?", email).First(&user).Error; err != nil { +func (r *UserRepository) FindByEmail(c context.Context, email string) (*uModel.UserDetails, error) { + var user *uModel.UserDetails + if err := r.db.WithContext(c).Table("users u").Select("u.*, c.webhook_url as webhook_url").Joins("left join circles c on c.id = u.circle_id").Where("email = ?", email).First(&user).Error; err != nil { return nil, err } return user, nil