diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index 73d392b6411..b1203561952 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -346,3 +346,12 @@ index: 2 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 +- id: 38 + title: 'task #37 done with due date' + done: true + created_by_id: 1 + list_id: 22 + index: 2 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + due_date: 2018-10-30 22:25:24 diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 2e820672e48..32e067ac2e1 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -93,6 +93,7 @@ func FullInit() { // Start the cron cron.Init() models.RegisterReminderCron() + models.RegisterOverdueReminderCron() // Start processing events go func() { diff --git a/pkg/models/task_overdue_reminder.go b/pkg/models/task_overdue_reminder.go new file mode 100644 index 00000000000..18f75b49174 --- /dev/null +++ b/pkg/models/task_overdue_reminder.go @@ -0,0 +1,97 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/cron" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/notifications" + "code.vikunja.io/api/pkg/utils" + "time" + "xorm.io/xorm" +) + +func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (taskIDs []int64, err error) { + now = utils.GetTimeWithoutNanoSeconds(now) + + var tasks []*Task + err = s. + Where("due_date is not null and due_date < ?", now.Format(dbTimeFormat)). + And("done = false"). + Find(&tasks) + if err != nil { + return + } + + for _, task := range tasks { + taskIDs = append(taskIDs, task.ID) + } + + return +} + +// RegisterOverdueReminderCron registers a function which checks once a day for tasks that are overdue and not done. +func RegisterOverdueReminderCron() { + if !config.ServiceEnableEmailReminders.GetBool() { + return + } + + if !config.MailerEnabled.GetBool() { + log.Info("Mailer is disabled, not sending overdue per mail") + return + } + + err := cron.Schedule("0 8 * * *", func() { + s := db.NewSession() + defer s.Close() + + now := time.Now() + taskIDs, err := getUndoneOverdueTasks(s, now) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not get tasks with reminders in the next minute: %s", err) + return + } + + users, err := getTaskUsersForTasks(s, taskIDs) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not get task users to send them reminders: %s", err) + return + } + + log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(users)) + + for _, u := range users { + n := &ReminderDueNotification{ + User: u.User, + Task: u.Task, + } + + err = notifications.Notify(u.User, n) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not notify user %d: %s", u.User.ID, err) + return + } + + log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder email for task %d to user %d", u.Task.ID, u.User.ID) + } + }) + if err != nil { + log.Fatalf("Could not register undone overdue tasks reminder cron: %s", err) + } +} diff --git a/pkg/models/task_overdue_reminder_test.go b/pkg/models/task_overdue_reminder_test.go new file mode 100644 index 00000000000..f48eeae0062 --- /dev/null +++ b/pkg/models/task_overdue_reminder_test.go @@ -0,0 +1,61 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/db" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestGetUndoneOverDueTasks(t *testing.T) { + t.Run("no undone tasks", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + now, err := time.Parse(time.RFC3339Nano, "2018-01-01T01:13:00Z") + assert.NoError(t, err) + taskIDs, err := getUndoneOverdueTasks(s, now) + assert.NoError(t, err) + assert.Len(t, taskIDs, 0) + }) + t.Run("undone overdue", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + now, err := time.Parse(time.RFC3339Nano, "2018-12-01T01:13:00Z") + assert.NoError(t, err) + taskIDs, err := getUndoneOverdueTasks(s, now) + assert.NoError(t, err) + assert.Len(t, taskIDs, 1) + assert.Equal(t, int64(6), taskIDs[0]) + }) + t.Run("done overdue", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + now, err := time.Parse(time.RFC3339Nano, "2018-11-01T01:13:00Z") + assert.NoError(t, err) + taskIDs, err := getUndoneOverdueTasks(s, now) + assert.NoError(t, err) + assert.Len(t, taskIDs, 0) + }) +} diff --git a/pkg/models/task_reminder.go b/pkg/models/task_reminder.go index f44c7065028..cc9e607f719 100644 --- a/pkg/models/task_reminder.go +++ b/pkg/models/task_reminder.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/utils" "time" "code.vikunja.io/api/pkg/notifications" @@ -48,6 +49,8 @@ type taskUser struct { User *user.User `xorm:"extends"` } +const dbTimeFormat = `2006-01-02 15:04:05` + func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUser, err error) { if len(taskIDs) == 0 { return @@ -103,13 +106,8 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUs } func getTasksWithRemindersInTheNextMinute(s *xorm.Session, now time.Time) (taskIDs []int64, err error) { + now = utils.GetTimeWithoutNanoSeconds(now) - tz := config.GetTimeZone() - const dbFormat = `2006-01-02 15:04:05` - - // By default, time.Now() includes nanoseconds which we don't save. That results in getting the wrong dates, - // so we make sure the time we use to get the reminders don't contain nanoseconds. - now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location()).In(tz) nextMinute := now.Add(1 * time.Minute) log.Debugf("[Task Reminder Cron] Looking for reminders between %s and %s to send...", now, nextMinute) @@ -117,7 +115,7 @@ func getTasksWithRemindersInTheNextMinute(s *xorm.Session, now time.Time) (taskI reminders := []*TaskReminder{} err = s. Join("INNER", "tasks", "tasks.id = task_reminders.task_id"). - Where("reminder >= ? and reminder < ?", now.Format(dbFormat), nextMinute.Format(dbFormat)). + Where("reminder >= ? and reminder < ?", now.Format(dbTimeFormat), nextMinute.Format(dbTimeFormat)). And("tasks.done = false"). Find(&reminders) if err != nil { @@ -154,9 +152,9 @@ func RegisterReminderCron() { log.Debugf("[Task Reminder Cron] Timezone is %s", tz) - s := db.NewSession() - err := cron.Schedule("* * * * *", func() { + s := db.NewSession() + defer s.Close() now := time.Now() taskIDs, err := getTasksWithRemindersInTheNextMinute(s, now) diff --git a/pkg/utils/time.go b/pkg/utils/time.go new file mode 100644 index 00000000000..b7d80828c5a --- /dev/null +++ b/pkg/utils/time.go @@ -0,0 +1,31 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package utils + +import ( + "code.vikunja.io/api/pkg/config" + "time" +) + +// GetTimeWithoutNanoSeconds returns a time.Time without the nanoseconds. +func GetTimeWithoutNanoSeconds(t time.Time) time.Time { + tz := config.GetTimeZone() + + // By default, time.Now() includes nanoseconds which we don't save. That results in getting the wrong dates, + // so we make sure the time we use to get the reminders don't contain nanoseconds. + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, t.Location()).In(tz) +}