diff --git a/config/local.yaml b/config/local.yaml index f9c888f..649abcd 100644 --- a/config/local.yaml +++ b/config/local.yaml @@ -8,6 +8,12 @@ pushover: database: type: "sqlite" migration: true + # these are only required for postgres + host: "secret" + port: 5432 + user: "secret" + password: "secret" + name: "secret" jwt: secret: "secret" session_time: 168h diff --git a/config/selfhosted.yaml b/config/selfhosted.yaml index 26e2eb5..37e9d9b 100644 --- a/config/selfhosted.yaml +++ b/config/selfhosted.yaml @@ -8,6 +8,12 @@ pushover: database: type: "sqlite" migration: true + # these are only required for postgres + host: "secret" + port: 5432 + user: "secret" + password: "secret" + name: "secret" jwt: secret: "secret" session_time: 168h diff --git a/internal/chore/handler.go b/internal/chore/handler.go index cf62ece..4000d84 100644 --- a/internal/chore/handler.go +++ b/internal/chore/handler.go @@ -231,7 +231,13 @@ func (h *Handler) createChore(c *gin.Context) { return } stringNotificationMetadata := string(notificationMetadataBytes) - + if stringNotificationMetadata == "null" { + // TODO: Clean this update after 0.1.38.. there is a bug in the frontend that sends null instead of empty object + // this is a temporary fix to avoid breaking changes + // once change we can change notificationMetadataBytes to var notificationMetadataMap map[string]interface{} to gernerate empty object + // and remove this check + stringNotificationMetadata = "{}" + } var stringLabels *string if len(choreReq.Labels) > 0 { var escapedLabels []string @@ -464,6 +470,13 @@ func (h *Handler) editChore(c *gin.Context) { return } stringNotificationMetadata := string(notificationMetadataBytes) + if stringNotificationMetadata == "null" { + // TODO: Clean this update after 0.1.38.. there is a bug in the frontend that sends null instead of empty object + // this is a temporary fix to avoid breaking changes + // once change we can change notificationMetadataBytes to var notificationMetadataMap map[string]interface{} to gernerate empty object + // and remove this check + stringNotificationMetadata = "{}" + } // escape special characters in labels and store them as a string : var stringLabels *string diff --git a/internal/chore/scheduler.go b/internal/chore/scheduler.go index b936278..3341cc2 100644 --- a/internal/chore/scheduler.go +++ b/internal/chore/scheduler.go @@ -38,9 +38,8 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T if err != nil { return nil, fmt.Errorf("error parsing time in frequency metadata: %w", err) } - + t = t.UTC() baseDate = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC) - // If the time is in the past today, move it to tomorrow if baseDate.Before(completedDate) { baseDate = baseDate.AddDate(0, 0, 1) diff --git a/internal/chore/scheduler_test.go b/internal/chore/scheduler_test.go index 14c81d2..2091579 100644 --- a/internal/chore/scheduler_test.go +++ b/internal/chore/scheduler_test.go @@ -119,7 +119,7 @@ func TestScheduleNextDueDateInterval(t *testing.T) { FrequencyMetadata: jsonPtr(`{"unit": "days","time":"2024-07-07T14:30:00-04:00"}`), }, completedDate: now, - want: timePtr(truncateToDay(now.AddDate(0, 0, 2)).Add(14*time.Hour + 30*time.Minute)), + want: timePtr(truncateToDay(now.AddDate(0, 0, 2)).Add(18*time.Hour + 30*time.Minute)), }, { name: "Interval - 4 Weeks", @@ -129,7 +129,7 @@ func TestScheduleNextDueDateInterval(t *testing.T) { FrequencyMetadata: jsonPtr(`{"unit": "weeks","time":"2024-07-07T14:30:00-04:00"}`), }, completedDate: now, - want: timePtr(truncateToDay(now.AddDate(0, 0, 4*7)).Add(14*time.Hour + 30*time.Minute)), + want: timePtr(truncateToDay(now.AddDate(0, 0, 4*7)).Add(18*time.Hour + 30*time.Minute)), }, { name: "Interval - 3 Months", @@ -139,7 +139,7 @@ func TestScheduleNextDueDateInterval(t *testing.T) { FrequencyMetadata: jsonPtr(`{"unit": "months","time":"2024-07-07T14:30:00-04:00"}`), }, completedDate: now, - want: timePtr(truncateToDay(now.AddDate(0, 3, 0)).Add(14*time.Hour + 30*time.Minute)), + want: timePtr(truncateToDay(now.AddDate(0, 3, 0)).Add(18*time.Hour + 30*time.Minute)), }, { name: "Interval - 2 Years", @@ -149,7 +149,7 @@ func TestScheduleNextDueDateInterval(t *testing.T) { FrequencyMetadata: jsonPtr(`{"unit": "years","time":"2024-07-07T14:30:00-04:00"}`), }, completedDate: now, - want: timePtr(truncateToDay(now.AddDate(2, 0, 0)).Add(14*time.Hour + 30*time.Minute)), + want: timePtr(truncateToDay(now.AddDate(2, 0, 0)).Add(18*time.Hour + 30*time.Minute)), }, } executeTestTable(t, tests) @@ -169,13 +169,13 @@ func TestScheduleNextDueDateDayOfWeek(t *testing.T) { chore: chModel.Chore{ FrequencyType: chModel.FrequencyTypeDayOfTheWeek, - FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T18:00:00-05:00"}`), + FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T01:00:00-05:00"}`), }, completedDate: now, want: func() *time.Time { // Calculate next Monday at 18:00 EST nextMonday := now.AddDate(0, 0, (int(time.Monday)-int(now.Weekday())+7)%7) - nextMonday = truncateToDay(nextMonday).Add(18*time.Hour + 0*time.Minute) + nextMonday = truncateToDay(nextMonday).Add(6*time.Hour + 0*time.Minute) return &nextMonday }(), }, @@ -184,7 +184,7 @@ func TestScheduleNextDueDateDayOfWeek(t *testing.T) { chore: chModel.Chore{ FrequencyType: chModel.FrequencyTypeDayOfTheWeek, IsRolling: true, - FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T18:00:00-05:00"}`), + FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T01:00:00-05:00"}`), }, completedDate: now.AddDate(0, 1, 0), @@ -192,7 +192,7 @@ func TestScheduleNextDueDateDayOfWeek(t *testing.T) { // Calculate next Thursday at 18:00 EST completedDate := now.AddDate(0, 1, 0) nextMonday := completedDate.AddDate(0, 0, (int(time.Monday)-int(completedDate.Weekday())+7)%7) - nextMonday = truncateToDay(nextMonday).Add(18*time.Hour + 0*time.Minute) + nextMonday = truncateToDay(nextMonday).Add(6*time.Hour + 0*time.Minute) return &nextMonday }(), }, @@ -214,10 +214,10 @@ func TestScheduleNextDueDateDayOfMonth(t *testing.T) { chore: chModel.Chore{ FrequencyType: chModel.FrequencyTypeDayOfTheMonth, Frequency: 15, - FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`), + FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T14:00:00-05:00", "days": [], "months": [ "january" ] }`), }, completedDate: now, - want: timePtr(time.Date(2025, 1, 15, 18, 0, 0, 0, location)), + want: timePtr(time.Date(2025, 1, 15, 19, 0, 0, 0, location)), }, { name: "Day of the month - 15th of January(isRolling)", @@ -225,10 +225,10 @@ func TestScheduleNextDueDateDayOfMonth(t *testing.T) { FrequencyType: chModel.FrequencyTypeDayOfTheMonth, Frequency: 15, IsRolling: true, - FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`), + FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T02:00:00-05:00", "days": [], "months": [ "january" ] }`), }, completedDate: now.AddDate(1, 1, 0), - want: timePtr(time.Date(2027, 1, 15, 18, 0, 0, 0, location)), + want: timePtr(time.Date(2027, 1, 15, 7, 0, 0, 0, location)), }, // test if completed before the 15th of the month: { diff --git a/internal/database/migration.go b/internal/database/migration.go index cfca864..54e7eb2 100644 --- a/internal/database/migration.go +++ b/internal/database/migration.go @@ -3,7 +3,9 @@ package database import ( "embed" "fmt" - "os" + + migrate "github.com/rubenv/sql-migrate" + "gorm.io/gorm" "donetick.com/core/config" chModel "donetick.com/core/internal/chore/model" @@ -13,9 +15,7 @@ import ( stModel "donetick.com/core/internal/subtask/model" tModel "donetick.com/core/internal/thing/model" uModel "donetick.com/core/internal/user/model" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details - migrations "donetick.com/core/migrations" - migrate "github.com/rubenv/sql-migrate" - "gorm.io/gorm" + "donetick.com/core/migrations" ) //go:embed migrations/*.sql @@ -52,9 +52,14 @@ func MigrationScripts(gormDB *gorm.DB, cfg *config.Config) error { Root: "migrations", } - path := os.Getenv("DT_SQLITE_PATH") - if path == "" { - path = "donetick.db" + var dialect string + switch cfg.Database.Type { + case "postgres": + dialect = "postgres" + case "sqlite": + dialect = "sqlite3" + default: + return fmt.Errorf("unsupported database type: %s", cfg.Database.Type) } db, err := gormDB.DB() @@ -62,7 +67,7 @@ func MigrationScripts(gormDB *gorm.DB, cfg *config.Config) error { return err } - n, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up) + n, err := migrate.Exec(db, dialect, migrations, migrate.Up) if err != nil { return err } diff --git a/internal/email/sender.go b/internal/email/sender.go index 540e54a..6afc8e1 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -32,7 +32,7 @@ func NewEmailSender(conf *config.Config) *EmailSender { func (es *EmailSender) SendVerificationEmail(to, code string) error { // msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body)) msg := gomail.NewMessage() - msg.SetHeader("From", "no-reply@donetick.com") + msg.SetHeader("From", es.client.Username) msg.SetHeader("To", to) msg.SetHeader("Subject", "Welcome to Donetick! Verifiy you email") // text/html for a html email @@ -259,7 +259,7 @@ func (es *EmailSender) SendVerificationEmail(to, code string) error { func (es *EmailSender) SendResetPasswordEmail(c context.Context, to, code string) error { msg := gomail.NewMessage() - msg.SetHeader("From", "no-reply@donetick.com") + msg.SetHeader("From", es.client.Username) msg.SetHeader("To", to) msg.SetHeader("Subject", "Donetick! Password Reset") htmlBody := ` diff --git a/internal/notifier/model/model.go b/internal/notifier/model/model.go index a3696df..b8efb48 100644 --- a/internal/notifier/model/model.go +++ b/internal/notifier/model/model.go @@ -37,6 +37,7 @@ const ( NotificationPlatformTelegram NotificationPlatformPushover NotificationPlatformWebhook + NotificationPlatformDiscord ) type JSONB map[string]interface{} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index 1ee47d4..8ecd3ac 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -5,6 +5,7 @@ import ( "donetick.com/core/internal/events" nModel "donetick.com/core/internal/notifier/model" + "donetick.com/core/internal/notifier/service/discord" pushover "donetick.com/core/internal/notifier/service/pushover" telegram "donetick.com/core/internal/notifier/service/telegram" @@ -14,14 +15,16 @@ import ( type Notifier struct { Telegram *telegram.TelegramNotifier Pushover *pushover.Pushover + discord *discord.DiscordNotifier eventsProducer *events.EventsProducer } -func NewNotifier(t *telegram.TelegramNotifier, p *pushover.Pushover, ep *events.EventsProducer) *Notifier { +func NewNotifier(t *telegram.TelegramNotifier, p *pushover.Pushover, ep *events.EventsProducer, d *discord.DiscordNotifier) *Notifier { return &Notifier{ Telegram: t, Pushover: p, eventsProducer: ep, + discord: d, } } @@ -41,6 +44,13 @@ func (n *Notifier) SendNotification(c context.Context, notification *nModel.Noti return nil } err = n.Pushover.SendNotification(c, notification) + case nModel.NotificationPlatformDiscord: + if n.discord == nil { + log.Error("Discord is not initialized, Skipping sending message") + return nil + } + err = n.discord.SendNotification(c, notification) + case nModel.NotificationPlatformWebhook: // TODO: Implement webhook notification // currently we have eventProducer to send events always as a webhook diff --git a/internal/notifier/service/discord/discord.go b/internal/notifier/service/discord/discord.go new file mode 100644 index 0000000..30aed57 --- /dev/null +++ b/internal/notifier/service/discord/discord.go @@ -0,0 +1,84 @@ +package discord + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "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" +) + +type DiscordNotifier struct { +} + +func NewDiscordNotifier(config *config.Config) *DiscordNotifier { + return &DiscordNotifier{} +} + +func (dn *DiscordNotifier) SendChoreCompletion(c context.Context, chore *chModel.Chore, user *uModel.User) { + log := logging.FromContext(c) + if dn == nil { + log.Error("Discord notifier is not initialized, skipping message sending") + return + } + + var mt *chModel.NotificationMetadata + if err := json.Unmarshal([]byte(*chore.NotificationMetadata), &mt); err != nil { + log.Error("Error unmarshalling notification metadata", err) + } + + message := fmt.Sprintf("🎉 **%s** is completed! Great job, %s! 🌟", chore.Name, user.DisplayName) + err := dn.sendMessage(c, user.UserNotificationTargets.TargetID, message) + if err != nil { + log.Error("Error sending Discord message:", err) + } +} + +func (dn *DiscordNotifier) SendNotification(c context.Context, notification *nModel.NotificationDetails) error { + + if dn == nil { + return errors.New("Discord notifier is not initialized") + } + + if notification.Text == "" { + return errors.New("unable to send notification, text is empty") + } + + return dn.sendMessage(c, notification.TargetID, notification.Text) +} + +func (dn *DiscordNotifier) sendMessage(c context.Context, webhookURL string, message string) error { + log := logging.FromContext(c) + + if webhookURL == "" { + return errors.New("unable to send notification, webhook URL is empty") + } + + payload := map[string]string{"content": message} + jsonData, err := json.Marshal(payload) + if err != nil { + log.Error("Error marshalling JSON:", err) + return err + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + log.Error("Error sending message to Discord:", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + log.Error("Discord webhook returned unexpected status:", resp.Status) + return errors.New("failed to send Discord message") + } + + return nil +} diff --git a/internal/subtask/model/model.go b/internal/subtask/model/model.go index 56e873d..e6dae3b 100644 --- a/internal/subtask/model/model.go +++ b/internal/subtask/model/model.go @@ -9,4 +9,5 @@ type SubTask struct { Name string `json:"name" gorm:"column:name"` CompletedAt *time.Time `json:"completedAt" gorm:"column:completed_at"` CompletedBy int `json:"completedBy" gorm:"column:completed_by"` + ParentId *int `json:"parentId" gorm:"column:parent_id"` } diff --git a/internal/subtask/repo/repository.go b/internal/subtask/repo/repository.go index 9cd0e39..1f0d8e7 100644 --- a/internal/subtask/repo/repository.go +++ b/internal/subtask/repo/repository.go @@ -37,7 +37,9 @@ func (r *SubTasksRepository) UpdateSubtask(c context.Context, choreId int, toBeR var insertions []stModel.SubTask var updates []stModel.SubTask for _, subtask := range toBeAdd { - if subtask.ID == 0 { + if subtask.ID <= 0 { + // we interpret this as a new subtask + subtask.ID = 0 insertions = append(insertions, subtask) } else { updates = append(updates, subtask) @@ -51,7 +53,14 @@ func (r *SubTasksRepository) UpdateSubtask(c context.Context, choreId int, toBeR } if len(updates) > 0 { for _, subtask := range updates { - if err := tx.Model(&stModel.SubTask{}).Where("chore_id = ? AND id = ?", choreId, subtask.ID).Updates(subtask).Error; err != nil { + values := map[string]interface{}{ + "name": subtask.Name, + "order_id": subtask.OrderID, + "completed_at": subtask.CompletedAt, + "completed_by": subtask.CompletedBy, + "parent_id": subtask.ParentId, + } + if err := tx.Model(&stModel.SubTask{}).Where("chore_id = ? AND id = ?", choreId, subtask.ID).Updates(values).Error; err != nil { return err } } @@ -61,12 +70,28 @@ func (r *SubTasksRepository) UpdateSubtask(c context.Context, choreId int, toBeR return nil }) } - func (r *SubTasksRepository) DeleteSubtask(c context.Context, tx *gorm.DB, subtaskID int) error { if tx != nil { - return tx.Delete(&stModel.SubTask{}, subtaskID).Error + return r.deleteSubtaskWithChildren(c, tx, subtaskID) } - return r.db.WithContext(c).Delete(&stModel.SubTask{}, subtaskID).Error + return r.db.WithContext(c).Transaction(func(tx *gorm.DB) error { + return r.deleteSubtaskWithChildren(c, tx, subtaskID) + }) +} + +func (r *SubTasksRepository) deleteSubtaskWithChildren(c context.Context, tx *gorm.DB, subtaskID int) error { + var childSubtasks []stModel.SubTask + if err := tx.Where("parent_id = ?", subtaskID).Find(&childSubtasks).Error; err != nil { + return err + } + + for _, child := range childSubtasks { + if err := r.deleteSubtaskWithChildren(c, tx, child.ID); err != nil { + return err + } + } + + return tx.Delete(&stModel.SubTask{}, subtaskID).Error } func (r *SubTasksRepository) UpdateSubTaskStatus(c context.Context, userID int, subtaskID int, completedAt *time.Time) error { diff --git a/main.go b/main.go index bc1048b..f2c6c71 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ import ( notifier "donetick.com/core/internal/notifier" nRepo "donetick.com/core/internal/notifier/repo" nps "donetick.com/core/internal/notifier/service" + discord "donetick.com/core/internal/notifier/service/discord" "donetick.com/core/internal/notifier/service/pushover" telegram "donetick.com/core/internal/notifier/service/telegram" pRepo "donetick.com/core/internal/points/repo" @@ -73,6 +74,7 @@ func main() { // add notifier fx.Provide(pushover.NewPushover), fx.Provide(telegram.NewTelegramNotifier), + fx.Provide(discord.NewDiscordNotifier), fx.Provide(notifier.NewNotifier), fx.Provide(events.NewEventsProducer), diff --git a/migrations/20250314_fix_notification_metadata_experiment_modal.go b/migrations/20250314_fix_notification_metadata_experiment_modal.go new file mode 100644 index 0000000..3d3618c --- /dev/null +++ b/migrations/20250314_fix_notification_metadata_experiment_modal.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "context" + + "donetick.com/core/logging" + "gorm.io/gorm" +) + +type MigrateFixNotificationMetadataExperimentModal20241212 struct{} + +func (m MigrateFixNotificationMetadataExperimentModal20241212) ID() string { + return "20250314_fix_notification_metadata_experiment_modal" +} + +func (m MigrateFixNotificationMetadataExperimentModal20241212) Description() string { + return `Fix notification metadata for experiment modal, where notification metadata is a null string 'null' to empty json {}` +} + +func (m MigrateFixNotificationMetadataExperimentModal20241212) Down(ctx context.Context, db *gorm.DB) error { + return nil +} + +func (m MigrateFixNotificationMetadataExperimentModal20241212) Up(ctx context.Context, db *gorm.DB) error { + log := logging.FromContext(ctx) + + // Start a transaction + return db.Transaction(func(tx *gorm.DB) error { + // Update all chore where notification metadata is a null stirng 'null' to empty json {}: + + if err := tx.Table("chores").Where("notification_meta = ?", "null").Update("notification_meta", "{}").Error; err != nil { + log.Errorf("Failed to update chores with null notification metadata: %v", err) + return err + } + + return nil + }) +} + +// Register this migration +func init() { + Register(MigrateFixNotificationMetadataExperimentModal20241212{}) +}