Add Event Producer

Update User to carry webhook from circle if assigned
Refactor notification handling and update models for webhook support
This commit is contained in:
Mo Tarbin 2025-02-09 20:15:28 -05:00
parent 44cb5501dd
commit 04d1894aea
17 changed files with 351 additions and 101 deletions

View file

@ -3,35 +3,32 @@ 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 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"`
ID int `json:"id" gorm:"primaryKey"`
ChoreID int `json:"chore_id" gorm:"column:chore_id"`
CircleID int `json:"circle_id" gorm:"column:circle_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 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"`
}
type NotificationDetails struct {
Notification
WebhookURL *string `json:"webhook_url" gorm:"column:webhook_url;<-:null"` // read-only, will only be used if webhook enabled
}
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
}
return true
}
type NotificationType int8
type NotificationPlatform int8
const (
NotificationTypeNone NotificationType = iota
NotificationTypeTelegram
NotificationTypePushover
NotificationPlatformNone NotificationPlatform = iota
NotificationPlatformTelegram
NotificationPlatformPushover
)

View file

@ -3,39 +3,52 @@ package notifier
import (
"context"
"donetick.com/core/internal/events"
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
Telegram *telegram.TelegramNotifier
Pushover *pushover.Pushover
eventsProducer *events.EventsProducer
}
func NewNotifier(t *telegram.TelegramNotifier, p *pushover.Pushover) *Notifier {
func NewNotifier(t *telegram.TelegramNotifier, p *pushover.Pushover, ep *events.EventsProducer) *Notifier {
return &Notifier{
Telegram: t,
Pushover: p,
Telegram: t,
Pushover: p,
eventsProducer: ep,
}
}
func (n *Notifier) SendNotification(c context.Context, notification *nModel.Notification) error {
func (n *Notifier) SendNotification(c context.Context, notification *nModel.NotificationDetails) error {
log := logging.FromContext(c)
var err error
switch notification.TypeID {
case nModel.NotificationTypeTelegram:
case nModel.NotificationPlatformTelegram:
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:
err = n.Telegram.SendNotification(c, notification)
case nModel.NotificationPlatformPushover:
if n.Pushover == nil {
log.Error("Pushover is not initialized, Skipping sending message")
return nil
}
return n.Pushover.SendNotification(c, notification)
err = n.Pushover.SendNotification(c, notification)
}
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
}

View file

@ -23,7 +23,7 @@ func (r *NotificationRepository) DeleteAllChoreNotifications(choreID int) error
func (r *NotificationRepository) BatchInsertNotifications(notifications []*nModel.Notification) error {
return r.db.Create(&notifications).Error
}
func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.Notification) error {
func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.NotificationDetails) error {
// Extract IDs from notifications
var ids []int
for _, notification := range notifications {
@ -32,11 +32,15 @@ func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel
// 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
func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookback time.Duration) ([]*nModel.NotificationDetails, error) {
var notifications []*nModel.NotificationDetails
start := time.Now().UTC().Add(-lookback)
end := time.Now().UTC()
if err := r.db.Where("is_sent = ? AND scheduled_for < ? AND scheduled_for > ?", false, end, start).Find(&notifications).Error; err != nil {
if err := r.db.Table("notifications").
Select("notifications.*, circles.webhook_url as webhook_url").
Joins("left join circles on circles.id = notifications.circle_id").
Where("notifications.is_sent = ? AND notifications.scheduled_for < ? AND notifications.scheduled_for > ?", false, end, start).
Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil

View file

@ -65,7 +65,7 @@ func (n *NotificationPlanner) GenerateNotifications(c context.Context, chore *ch
if mt.CircleGroup {
notifications = append(notifications, generateCircleGroupNotifications(chore, mt)...)
}
log.Debug("Generated notifications", "count", len(notifications))
n.nRepo.BatchInsertNotifications(notifications)
return true
}
@ -89,6 +89,13 @@ func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDe
UserID: user.ID,
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"),
"assignee": assignee.DisplayName,
"assignee_username": assignee.Username,
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -117,6 +124,13 @@ func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircl
UserID: user.ID,
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"),
"assignee": assignee.DisplayName,
"assignee_username": assignee.Username,
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -148,6 +162,14 @@ func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCirc
UserID: user.ID,
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"),
"assignee": assignee.DisplayName,
"assignee_username": assignee.Username,
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -173,6 +195,12 @@ 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),
RawEvent: map[string]interface{}{
"id": chore.ID,
"type": EventTypeDue,
"name": chore.Name,
"due_date": chore.NextDueDate.Format("January 2nd"),
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -188,6 +216,12 @@ 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")),
RawEvent: map[string]interface{}{
"id": chore.ID,
"type": EventTypePreDue,
"name": chore.Name,
"due_date": chore.NextDueDate.Format("January 2nd"),
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -205,6 +239,12 @@ 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),
RawEvent: map[string]interface{}{
"id": chore.ID,
"type": EventTypeOverdue,
"name": chore.Name,
"due_date": chore.NextDueDate.Format("January 2nd"),
},
}
if notification.IsValid() {
notifications = append(notifications, notification)
@ -214,3 +254,12 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica
return notifications
}
type EventType string
const (
EventTypeUnknown EventType = "unknown"
EventTypeDue EventType = "due"
EventTypePreDue EventType = "pre_due"
EventTypeOverdue EventType = "overdue"
)

View file

@ -2,6 +2,7 @@ package pushover
import (
"context"
"errors"
"donetick.com/core/config"
nModel "donetick.com/core/internal/notifier/model"
@ -22,7 +23,10 @@ func NewPushover(cfg *config.Config) *Pushover {
}
}
func (p *Pushover) SendNotification(c context.Context, notification *nModel.Notification) error {
func (p *Pushover) SendNotification(c context.Context, notification *nModel.NotificationDetails) error {
if notification.TargetID == "" {
return errors.New("unable to send notification, targetID is empty")
}
log := logging.FromContext(c)
recipient := pushover.NewRecipient(notification.TargetID)
message := pushover.NewMessageWithTitle(notification.Text, "Donetick")

View file

@ -70,12 +70,10 @@ func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chMode
}
func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.Notification) error {
func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.NotificationDetails) error {
log := logging.FromContext(c)
if notification.TargetID == "" {
log.Error("Notification target ID is empty")
return errors.New("Notification target ID is empty")
return errors.New("unable to send notification, targetID is empty")
}
chatID, err := strconv.ParseInt(notification.TargetID, 10, 64)
if err != nil {