- Assign default circle to user when leaving a circle

- Support Pushover
- Support Disable Signup
- Migrate chatID to TargetID
This commit is contained in:
Mo Tarbin 2024-12-14 02:15:51 -05:00
parent 850d472445
commit adf5c0c0cd
20 changed files with 362 additions and 151 deletions

View file

@ -9,21 +9,27 @@ import (
) )
type Config struct { type Config struct {
Name string `mapstructure:"name" yaml:"name"` Name string `mapstructure:"name" yaml:"name"`
Telegram TelegramConfig `mapstructure:"telegram" yaml:"telegram"` Telegram TelegramConfig `mapstructure:"telegram" yaml:"telegram"`
Database DatabaseConfig `mapstructure:"database" yaml:"database"` Pushover PushoverConfig `mapstructure:"pushover" yaml:"pushover"`
Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"` Database DatabaseConfig `mapstructure:"database" yaml:"database"`
Server ServerConfig `mapstructure:"server" yaml:"server"` Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"`
SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"` Server ServerConfig `mapstructure:"server" yaml:"server"`
EmailConfig EmailConfig `mapstructure:"email" yaml:"email"` SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"`
StripeConfig StripeConfig `mapstructure:"stripe" yaml:"stripe"` EmailConfig EmailConfig `mapstructure:"email" yaml:"email"`
IsDoneTickDotCom bool `mapstructure:"is_done_tick_dot_com" yaml:"is_done_tick_dot_com"` 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 { type TelegramConfig struct {
Token string `mapstructure:"token" yaml:"token"` Token string `mapstructure:"token" yaml:"token"`
} }
type PushoverConfig struct {
Token string `mapstructure:"token" yaml:"token"`
}
type DatabaseConfig struct { type DatabaseConfig struct {
Type string `mapstructure:"type" yaml:"type"` Type string `mapstructure:"type" yaml:"type"`
Host string `mapstructure:"host" yaml:"host"` Host string `mapstructure:"host" yaml:"host"`
@ -98,6 +104,13 @@ func configEnvironmentOverrides(Config *Config) {
if os.Getenv("DONETICK_TELEGRAM_TOKEN") != "" { if os.Getenv("DONETICK_TELEGRAM_TOKEN") != "" {
Config.Telegram.Token = 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 { func LoadConfig() *Config {
// set the config name based on the environment: // set the config name based on the environment:

View file

@ -1,7 +1,10 @@
name: "local" name: "local"
is_done_tick_dot_com: false is_done_tick_dot_com: false
is_user_creation_disabled: false
telegram: telegram:
token: "" token: ""
pushover:
token: ""
database: database:
type: "sqlite" type: "sqlite"
migration: true migration: true

View file

@ -1,7 +1,10 @@
name: "selhosted" name: "selhosted"
is_done_tick_dot_com: false is_done_tick_dot_com: false
is_user_creation_disabled: false
telegram: telegram:
token: "" token: ""
pushover:
token: ""
database: database:
type: "sqlite" type: "sqlite"
migration: true migration: true

1
go.mod
View file

@ -50,6 +50,7 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // 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/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect

2
go.sum
View file

@ -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/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 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= 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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View file

@ -16,9 +16,9 @@ import (
chRepo "donetick.com/core/internal/chore/repo" chRepo "donetick.com/core/internal/chore/repo"
cRepo "donetick.com/core/internal/circle/repo" cRepo "donetick.com/core/internal/circle/repo"
lRepo "donetick.com/core/internal/label/repo" lRepo "donetick.com/core/internal/label/repo"
"donetick.com/core/internal/notifier"
nRepo "donetick.com/core/internal/notifier/repo" nRepo "donetick.com/core/internal/notifier/repo"
nps "donetick.com/core/internal/notifier/service" nps "donetick.com/core/internal/notifier/service"
telegram "donetick.com/core/internal/notifier/telegram"
tRepo "donetick.com/core/internal/thing/repo" tRepo "donetick.com/core/internal/thing/repo"
uModel "donetick.com/core/internal/user/model" uModel "donetick.com/core/internal/user/model"
"donetick.com/core/logging" "donetick.com/core/logging"
@ -57,14 +57,14 @@ type ChoreReq struct {
type Handler struct { type Handler struct {
choreRepo *chRepo.ChoreRepository choreRepo *chRepo.ChoreRepository
circleRepo *cRepo.CircleRepository circleRepo *cRepo.CircleRepository
notifier *telegram.TelegramNotifier notifier *notifier.Notifier
nPlanner *nps.NotificationPlanner nPlanner *nps.NotificationPlanner
nRepo *nRepo.NotificationRepository nRepo *nRepo.NotificationRepository
tRepo *tRepo.ThingRepository tRepo *tRepo.ThingRepository
lRepo *lRepo.LabelRepository 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 { np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository, lRepo *lRepo.LabelRepository) *Handler {
return &Handler{ return &Handler{
choreRepo: cr, choreRepo: cr,
@ -928,10 +928,12 @@ func (h *Handler) completeChore(c *gin.Context) {
}) })
return return
} }
go func() { // go func() {
h.notifier.SendChoreCompletion(c, chore, currentUser)
h.nPlanner.GenerateNotifications(c, updatedChore) // h.notifier.SendChoreCompletion(c, chore, currentUser)
}() // }()
h.nPlanner.GenerateNotifications(c, updatedChore)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"res": updatedChore, "res": updatedChore,
}) })

View file

@ -1,6 +1,10 @@
package circle package circle
import "time" import (
"time"
nModel "donetick.com/core/internal/notifier/model"
)
type Circle struct { type Circle struct {
ID int `json:"id" gorm:"primary_key"` // Unique identifier ID int `json:"id" gorm:"primary_key"` // Unique identifier
@ -29,7 +33,8 @@ type UserCircle struct {
type UserCircleDetail struct { type UserCircleDetail struct {
UserCircle UserCircle
Username string `json:"username" gorm:"column:username"` Username string `json:"username" gorm:"column:username"`
DisplayName string `json:"displayName" gorm:"column:display_name"` DisplayName string `json:"displayName" gorm:"column:display_name"`
ChatID int `json:"chatID" gorm:"column:chat_id"` NotificationType nModel.NotificationType `json:"-" gorm:"column:notification_type"`
TargetID string `json:"-" gorm:"column:target_id"` // Target ID
} }

View file

@ -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) { func (r *CircleRepository) GetCircleUsers(c context.Context, circleID int) ([]*cModel.UserCircleDetail, error) {
var circleUsers []*cModel.UserCircleDetail var circleUsers []*cModel.UserCircleDetail
// join user table to get user details like username and display name: if err := r.db.WithContext(c).
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 { 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 nil, err
} }
return circleUsers, nil return circleUsers, nil

View file

@ -3,13 +3,35 @@ package model
import "time" import "time"
type Notification struct { type Notification struct {
ID int `json:"id" gorm:"primaryKey"` ID int `json:"id" gorm:"primaryKey"`
ChoreID int `json:"chore_id" gorm:"column:chore_id"` ChoreID int `json:"chore_id" gorm:"column:chore_id"`
UserID int `json:"user_id" gorm:"column:user_id"` UserID int `json:"user_id" gorm:"column:user_id"`
TargetID string `json:"target_id" gorm:"column:target_id"` TargetID string `json:"target_id" gorm:"column:target_id"`
Text string `json:"text" gorm:"column:text"` Text string `json:"text" gorm:"column:text"`
IsSent bool `json:"is_sent" gorm:"column:is_sent;index;default:false"` IsSent bool `json:"is_sent" gorm:"column:is_sent;index;default:false"`
TypeID int `json:"type" gorm:"column:type"` TypeID NotificationType `json:"type" gorm:"column:type"`
ScheduledFor time.Time `json:"scheduled_for" gorm:"column:scheduled_for;index"` ScheduledFor time.Time `json:"scheduled_for" gorm:"column:scheduled_for;index"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` 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
)

View file

@ -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
}

View file

@ -41,3 +41,7 @@ func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookb
} }
return notifications, nil 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
}

View file

@ -8,7 +8,6 @@ import (
"donetick.com/core/config" "donetick.com/core/config"
chRepo "donetick.com/core/internal/chore/repo" chRepo "donetick.com/core/internal/chore/repo"
nRepo "donetick.com/core/internal/notifier/repo" nRepo "donetick.com/core/internal/notifier/repo"
notifier "donetick.com/core/internal/notifier/telegram"
uRepo "donetick.com/core/internal/user/repo" uRepo "donetick.com/core/internal/user/repo"
"donetick.com/core/logging" "donetick.com/core/logging"
) )
@ -23,12 +22,12 @@ type Scheduler struct {
choreRepo *chRepo.ChoreRepository choreRepo *chRepo.ChoreRepository
userRepo *uRepo.UserRepository userRepo *uRepo.UserRepository
stopChan chan bool stopChan chan bool
notifier *notifier.TelegramNotifier notifier *Notifier
notificationRepo *nRepo.NotificationRepository notificationRepo *nRepo.NotificationRepository
SchedulerJobs config.SchedulerConfig 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{ return &Scheduler{
choreRepo: cr, choreRepo: cr,
userRepo: ur, userRepo: ur,
@ -43,12 +42,23 @@ func (s *Scheduler) Start(c context.Context) {
log := logging.FromContext(c) log := logging.FromContext(c)
log.Debug("Scheduler started") log.Debug("Scheduler started")
go s.runScheduler(c, " NOTIFICATION_SCHEDULER ", s.loadAndSendNotificationJob, 3*time.Minute) 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) { func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration, error) {
log := logging.FromContext(c) log := logging.FromContext(c)
startTime := time.Now() 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)) log.Debug("Getting pending notifications", " count ", len(getAllPendingNotifications))
if err != nil { if err != nil {
@ -57,7 +67,11 @@ func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration
} }
for _, notification := range getAllPendingNotifications { 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 notification.IsSent = true
} }
@ -78,7 +92,7 @@ func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c c
if err != nil { if err != nil {
logging.FromContext(c).Error("Error running scheduler job", err) 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) time.Sleep(interval)
} }

View file

@ -80,18 +80,19 @@ func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDe
} }
} }
for _, user := range users { for _, user := range users {
notification := &nModel.Notification{ notification := &nModel.Notification{
ChoreID: chore.ID, ChoreID: chore.ID,
IsSent: false, IsSent: false,
ScheduledFor: *chore.NextDueDate, ScheduledFor: *chore.NextDueDate,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
TypeID: 1, TypeID: user.NotificationType,
UserID: user.ID, 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), 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 return notifications
@ -112,12 +113,14 @@ func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircl
IsSent: false, IsSent: false,
ScheduledFor: *chore.NextDueDate, ScheduledFor: *chore.NextDueDate,
CreatedAt: time.Now().UTC().Add(-time.Hour * 3), CreatedAt: time.Now().UTC().Add(-time.Hour * 3),
TypeID: 3, TypeID: user.NotificationType,
UserID: user.ID, 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), 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 return notifications
@ -141,12 +144,14 @@ func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCirc
IsSent: false, IsSent: false,
ScheduledFor: scheduleTime, ScheduledFor: scheduleTime,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
TypeID: 2, TypeID: user.NotificationType,
UserID: user.ID, 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), 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 return notifications
} }
if mt.DueDate { if mt.DueDate {
notifications = append(notifications, &nModel.Notification{ notification := &nModel.Notification{
ChoreID: chore.ID, ChoreID: chore.ID,
IsSent: false, IsSent: false,
ScheduledFor: *chore.NextDueDate, ScheduledFor: *chore.NextDueDate,
@ -168,10 +173,14 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica
TypeID: 1, TypeID: 1,
TargetID: fmt.Sprint(*mt.CircleGroupID), TargetID: fmt.Sprint(*mt.CircleGroupID),
Text: fmt.Sprintf("📅 Reminder: *%s* is due today.", chore.Name), Text: fmt.Sprintf("📅 Reminder: *%s* is due today.", chore.Name),
}) }
if notification.IsValid() {
notifications = append(notifications, notification)
}
} }
if mt.PreDue { if mt.PreDue {
notifications = append(notifications, &nModel.Notification{ notification := &nModel.Notification{
ChoreID: chore.ID, ChoreID: chore.ID,
IsSent: false, IsSent: false,
ScheduledFor: *chore.NextDueDate, ScheduledFor: *chore.NextDueDate,
@ -179,12 +188,16 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica
TypeID: 3, TypeID: 3,
TargetID: fmt.Sprint(*mt.CircleGroupID), TargetID: fmt.Sprint(*mt.CircleGroupID),
Text: fmt.Sprintf("📢 Heads up! *%s* is due soon (on %s).", chore.Name, chore.NextDueDate.Format("January 2nd")), 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 { if mt.Nagging {
for _, hours := range []int{24, 48, 72} { for _, hours := range []int{24, 48, 72} {
scheduleTime := chore.NextDueDate.Add(time.Hour * time.Duration(hours)) scheduleTime := chore.NextDueDate.Add(time.Hour * time.Duration(hours))
notifications = append(notifications, &nModel.Notification{ notification := &nModel.Notification{
ChoreID: chore.ID, ChoreID: chore.ID,
IsSent: false, IsSent: false,
ScheduledFor: scheduleTime, ScheduledFor: scheduleTime,
@ -192,7 +205,10 @@ func generateCircleGroupNotifications(chore *chModel.Chore, mt *chModel.Notifica
TypeID: 2, TypeID: 2,
TargetID: fmt.Sprint(*mt.CircleGroupID), TargetID: fmt.Sprint(*mt.CircleGroupID),
Text: fmt.Sprintf("🚨 *%s* is now %d hours overdue. Please complete it as soon as possible.", chore.Name, hours), 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)
}
} }
} }

View file

@ -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
}

View file

@ -3,6 +3,7 @@ package telegram
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "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) { func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chModel.Chore, user *uModel.User) {
log := logging.FromContext(c) 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) { func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.Notification) error {
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) log := logging.FromContext(c)
if notification.TargetID == "" { if notification.TargetID == "" {
log.Error("Notification target ID is empty") log.Error("Notification target ID is empty")
return return errors.New("Notification target ID is empty")
} }
chatID, err := strconv.ParseInt(notification.TargetID, 10, 64) chatID, err := strconv.ParseInt(notification.TargetID, 10, 64)
if err != nil { if err != nil {
log.Error("Error parsing chatID: ", err) log.Error("Error parsing chatID: ", err)
return return err
} }
msg := tgbotapi.NewMessage(chatID, notification.Text) msg := tgbotapi.NewMessage(chatID, notification.Text)
@ -145,5 +89,7 @@ func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nM
if err != nil { if err != nil {
log.Error("Error sending message to user: ", err) log.Error("Error sending message to user: ", err)
log.Debug("Error sending message, notification: ", notification.Text, " chatID: ", chatID) log.Debug("Error sending message, notification: ", notification.Text, " chatID: ", chatID)
return err
} }
return nil
} }

View file

@ -14,6 +14,7 @@ import (
cModel "donetick.com/core/internal/circle/model" cModel "donetick.com/core/internal/circle/model"
cRepo "donetick.com/core/internal/circle/repo" cRepo "donetick.com/core/internal/circle/repo"
"donetick.com/core/internal/email" "donetick.com/core/internal/email"
nModel "donetick.com/core/internal/notifier/model"
uModel "donetick.com/core/internal/user/model" uModel "donetick.com/core/internal/user/model"
uRepo "donetick.com/core/internal/user/repo" uRepo "donetick.com/core/internal/user/repo"
"donetick.com/core/internal/utils" "donetick.com/core/internal/utils"
@ -26,20 +27,22 @@ import (
) )
type Handler struct { type Handler struct {
userRepo *uRepo.UserRepository userRepo *uRepo.UserRepository
circleRepo *cRepo.CircleRepository circleRepo *cRepo.CircleRepository
jwtAuth *jwt.GinJWTMiddleware jwtAuth *jwt.GinJWTMiddleware
email *email.EmailSender email *email.EmailSender
isDonetickDotCom bool isDonetickDotCom bool
IsUserCreationDisabled bool
} }
func NewHandler(ur *uRepo.UserRepository, cr *cRepo.CircleRepository, jwtAuth *jwt.GinJWTMiddleware, email *email.EmailSender, config *config.Config) *Handler { func NewHandler(ur *uRepo.UserRepository, cr *cRepo.CircleRepository, jwtAuth *jwt.GinJWTMiddleware, email *email.EmailSender, config *config.Config) *Handler {
return &Handler{ return &Handler{
userRepo: ur, userRepo: ur,
circleRepo: cr, circleRepo: cr,
jwtAuth: jwtAuth, jwtAuth: jwtAuth,
email: email, email: email,
isDonetickDotCom: config.IsDoneTickDotCom, isDonetickDotCom: config.IsDoneTickDotCom,
IsUserCreationDisabled: config.IsUserCreationDisabled,
} }
} }
@ -68,6 +71,12 @@ func (h *Handler) GetAllUsers() gin.HandlerFunc {
} }
func (h *Handler) signUp(c *gin.Context) { func (h *Handler) signUp(c *gin.Context) {
if h.IsUserCreationDisabled {
c.JSON(403, gin.H{
"error": "User creation is disabled",
})
return
}
type SignUpReq struct { type SignUpReq struct {
Username string `json:"username" binding:"required,min=4,max=20"` 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 Request struct {
Type uModel.UserNotificationType `json:"type" binding:"required"` Type nModel.NotificationType `json:"type"`
Token string `json:"token" binding:"required"` Target string `json:"target" binding:"required"`
} }
var req Request var req Request
@ -506,13 +515,28 @@ func (h *Handler) UpdateNotificationTarget(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification target"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification target"})
return 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{}) 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 := router.Group("auth")
authRoutes.Use(utils.RateLimitMiddleware(limiter)) authRoutes.Use(utils.RateLimitMiddleware(limiter))
{ {
authRoutes.POST("/:provider/callback", h.thirdPartyAuthCallback)
authRoutes.POST("/", h.signUp) authRoutes.POST("/", h.signUp)
authRoutes.POST("login", auth.LoginHandler) authRoutes.POST("login", auth.LoginHandler)
authRoutes.GET("refresh", auth.RefreshHandler) authRoutes.GET("refresh", auth.RefreshHandler)
authRoutes.POST("/:provider/callback", h.thirdPartyAuthCallback)
authRoutes.POST("reset", h.resetPassword) authRoutes.POST("reset", h.resetPassword)
authRoutes.POST("password", h.updateUserPassword) authRoutes.POST("password", h.updateUserPassword)
} }
} }

View file

@ -1,6 +1,10 @@
package user package user
import "time" import (
"time"
nModel "donetick.com/core/internal/notifier/model"
)
type User struct { type User struct {
ID int `json:"id" gorm:"primary_key"` // Unique identifier 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 UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` // Updated at
Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled
// Email string `json:"email" gorm:"column:email"` // Email // Email string `json:"email" gorm:"column:email"` // Email
CustomerID *string `gorm:"column:customer_id;<-:false"` // read only column CustomerID *string `gorm:"column:customer_id;<-:false"` // read only column
Subscription *string `json:"subscription" gorm:"column:subscription;<-: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 Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read only column
UserNotificationTargets []UserNotificationTarget `json:"-" gorm:"foreignKey:UserID;references:ID"` UserNotificationTargets UserNotificationTarget `json:"notification_target" gorm:"foreignKey:UserID;references:ID"`
} }
type UserPasswordReset struct { type UserPasswordReset struct {
@ -39,18 +43,8 @@ type APIToken struct {
} }
type UserNotificationTarget struct { type UserNotificationTarget struct {
ID int `json:"id" gorm:"primary_key"` // Unique identifier UserID int `json:"userId" gorm:"column:user_id;index;primaryKey"` // Index on userID
UserID int `json:"userId" gorm:"column:user_id;index"` // Index on userID Type nModel.NotificationType `json:"type" gorm:"column:type"` // Type
Type UserNotificationType `json:"type" gorm:"column:type"` // Type TargetID string `json:"target_id" gorm:"column:target_id"` // Target ID
TargetID string `json:"targetId" gorm:"column:target_id"` // Target ID CreatedAt time.Time `json:"-" gorm:"column:created_at"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
} }
type UserNotificationType int8
const (
_ UserNotificationType = iota
Android
IOS
Telegram
)

View file

@ -6,6 +6,7 @@ import (
"time" "time"
"donetick.com/core/config" "donetick.com/core/config"
nModel "donetick.com/core/internal/notifier/model"
uModel "donetick.com/core/internal/user/model" uModel "donetick.com/core/internal/user/model"
"donetick.com/core/logging" "donetick.com/core/logging"
"gorm.io/gorm" "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) { func (r *UserRepository) GetUserByUsername(c context.Context, username string) (*uModel.User, error) {
var user *uModel.User var user *uModel.User
if r.isDonetickDotCom { 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 return nil, err
} }
} else { } 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 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 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 { func (r *UserRepository) UpdateNotificationTarget(c context.Context, userID int, targetID string, targetType nModel.NotificationType) error {
return r.db.WithContext(c).Model(&uModel.UserNotificationTarget{}).Where("user_id = ? AND type = ?", userID, targetType).Update("target_id", targetID).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 { 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 return r.db.WithContext(c).Model(&uModel.User{}).Where("id = ?", userID).Update("password", password).Error
} }

View file

@ -28,7 +28,8 @@ import (
notifier "donetick.com/core/internal/notifier" notifier "donetick.com/core/internal/notifier"
nRepo "donetick.com/core/internal/notifier/repo" nRepo "donetick.com/core/internal/notifier/repo"
nps "donetick.com/core/internal/notifier/service" 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" "donetick.com/core/internal/thing"
tRepo "donetick.com/core/internal/thing/repo" tRepo "donetick.com/core/internal/thing/repo"
"donetick.com/core/internal/user" "donetick.com/core/internal/user"
@ -64,7 +65,9 @@ func main() {
fx.Provide(nps.NewNotificationPlanner), fx.Provide(nps.NewNotificationPlanner),
// add notifier // add notifier
fx.Provide(pushover.NewPushover),
fx.Provide(telegram.NewTelegramNotifier), fx.Provide(telegram.NewTelegramNotifier),
fx.Provide(notifier.NewNotifier),
// Rate limiter // Rate limiter
fx.Provide(utils.NewRateLimiter), fx.Provide(utils.NewRateLimiter),

View file

@ -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(&notificationTargets).Error; err != nil {
log.Errorf("Failed to insert notification targets: %v", err)
}
return nil
})
}
// Register this migration
func init() {
Register(MigrateChatIdToNotificationTarget20241212{})
}