From 8869adfc276f674b686bf68f949d7efbb417e55b Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 16 Jun 2022 16:20:26 +0200 Subject: [PATCH] feat: add setting to change overdue tasks reminder email time --- pkg/models/label_task_test.go | 1 + pkg/models/label_test.go | 4 +++ pkg/models/list_users_test.go | 2 ++ pkg/models/namespace_users_test.go | 2 ++ pkg/models/task_collection_test.go | 3 ++ pkg/models/task_overdue_reminder.go | 12 ++++--- pkg/models/task_reminder.go | 2 +- pkg/models/users_list_test.go | 13 +++++++ pkg/routes/api/v1/user_settings.go | 18 ++++++++-- pkg/routes/api/v1/user_show.go | 1 + pkg/routes/routes.go | 26 -------------- pkg/routes/validation.go | 55 +++++++++++++++++++++++++++++ pkg/swagger/docs.go | 4 +++ pkg/swagger/swagger.json | 4 +++ pkg/swagger/swagger.yaml | 4 +++ pkg/user/user.go | 2 ++ pkg/utils/time.go | 9 +++++ 17 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 pkg/routes/validation.go diff --git a/pkg/models/label_task_test.go b/pkg/models/label_task_test.go index 925358bc..451e308e 100644 --- a/pkg/models/label_task_test.go +++ b/pkg/models/label_task_test.go @@ -43,6 +43,7 @@ func TestLabelTask_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index 52151d01..a88cedb7 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -54,6 +54,7 @@ func TestLabel_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -104,6 +105,7 @@ func TestLabel_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -168,6 +170,7 @@ func TestLabel_ReadOne(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -229,6 +232,7 @@ func TestLabel_ReadOne(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/list_users_test.go b/pkg/models/list_users_test.go index 3ee8722f..7ba9d443 100644 --- a/pkg/models/list_users_test.go +++ b/pkg/models/list_users_test.go @@ -151,6 +151,7 @@ func TestListUser_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -164,6 +165,7 @@ func TestListUser_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/namespace_users_test.go b/pkg/models/namespace_users_test.go index 0cc288bd..32f4d8b9 100644 --- a/pkg/models/namespace_users_test.go +++ b/pkg/models/namespace_users_test.go @@ -150,6 +150,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -163,6 +164,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 97f5f1ea..b15d77fe 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -37,6 +37,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -47,6 +48,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -57,6 +59,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } diff --git a/pkg/models/task_overdue_reminder.go b/pkg/models/task_overdue_reminder.go index 6d1b7b44..a96db958 100644 --- a/pkg/models/task_overdue_reminder.go +++ b/pkg/models/task_overdue_reminder.go @@ -32,7 +32,7 @@ import ( ) func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[int64]*userWithTasks, err error) { - now = utils.GetTimeWithoutNanoSeconds(now) + now = utils.GetTimeWithoutSeconds(now) nextMinute := now.Add(1 * time.Minute) var tasks []*Task @@ -78,10 +78,14 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[i tzs[t.User.Timezone] = tz } - // If it is 9:00 for that current user, add the task to their list of overdue tasks - overdueMailTime := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, tz) + // If it is time for that current user, add the task to their list of overdue tasks + tm, err := time.Parse("15:04", t.User.OverdueTasksRemindersTime) + if err != nil { + return nil, err + } + overdueMailTime := time.Date(now.Year(), now.Month(), now.Day(), tm.Hour(), tm.Minute(), 0, 0, tz) isTimeForReminder := overdueMailTime.After(now) || overdueMailTime.Equal(now.In(tz)) - wasTimeForReminder := overdueMailTime.Before(now.Add(time.Minute)) + wasTimeForReminder := overdueMailTime.Before(nextMinute) taskIsOverdueInUserTimezone := overdueMailTime.After(t.Task.DueDate.In(tz)) if isTimeForReminder && wasTimeForReminder && taskIsOverdueInUserTimezone { _, exists := uts[t.User.ID] diff --git a/pkg/models/task_reminder.go b/pkg/models/task_reminder.go index 620d1ab9..42b337b6 100644 --- a/pkg/models/task_reminder.go +++ b/pkg/models/task_reminder.go @@ -61,7 +61,7 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64, cond builder.Cond) ( // Get all creators of tasks creators := make(map[int64]*user.User, len(taskIDs)) err = s. - Select("users.id, users.username, users.email, users.name, users.timezone"). + Select("users.*"). Join("LEFT", "tasks", "tasks.created_by_id = users.id"). In("tasks.id", taskIDs). Where(cond). diff --git a/pkg/models/users_list_test.go b/pkg/models/users_list_test.go index cf0ed846..1a48c9de 100644 --- a/pkg/models/users_list_test.go +++ b/pkg/models/users_list_test.go @@ -32,6 +32,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -42,6 +43,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -52,6 +54,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -63,6 +66,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -74,6 +78,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -84,6 +89,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -95,6 +101,7 @@ func TestListUsersFromList(t *testing.T) { EmailRemindersEnabled: true, DiscoverableByEmail: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -105,6 +112,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -115,6 +123,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -125,6 +134,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -136,6 +146,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -148,6 +159,7 @@ func TestListUsersFromList(t *testing.T) { EmailRemindersEnabled: true, DiscoverableByName: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -158,6 +170,7 @@ func TestListUsersFromList(t *testing.T) { Issuer: "local", EmailRemindersEnabled: true, OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", Created: testCreatedTime, Updated: testUpdatedTime, } diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 157160aa..536a427a 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -17,6 +17,8 @@ package v1 import ( + "errors" + "fmt" "net/http" "github.com/labstack/echo/v4" @@ -46,11 +48,13 @@ type UserSettings struct { DiscoverableByEmail bool `json:"discoverable_by_email"` // If enabled, the user will get an email for their overdue tasks each morning. OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` + // The time when the daily summary of overdue tasks will be sent via email. + OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` // If a task is created without a specified list this value should be used. Applies // to tasks made directly in API and from clients. DefaultListID int64 `json:"default_list_id"` // The day when the week starts for this user. 0 = sunday, 1 = monday, etc. - WeekStart int `json:"week_start"` + WeekStart int `json:"week_start" valid:"range(0|7)"` // The user's language Language string `json:"language"` // The user's time zone. Used to send task reminders in the time zone of the user. @@ -158,7 +162,16 @@ func UpdateGeneralUserSettings(c echo.Context) error { us := &UserSettings{} err := c.Bind(us) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.") + var he *echo.HTTPError + if errors.As(err, &he) { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided. Error was: %s", he.Message)) + } + return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.") + } + + err = c.Validate(us) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err) } u, err := user2.GetCurrentUser(c) @@ -184,6 +197,7 @@ func UpdateGeneralUserSettings(c echo.Context) error { user.WeekStart = us.WeekStart user.Language = us.Language user.Timezone = us.Timezone + user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime _, err = user2.UpdateUser(s, user) if err != nil { diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index 9bcdb348..d1e6f1da 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -75,6 +75,7 @@ func UserShow(c echo.Context) error { WeekStart: u.WeekStart, Language: u.Language, Timezone: u.Timezone, + OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, }, DeletionScheduledAt: u.DeletionScheduledAt, IsLocalUser: u.Issuer == user.IssuerLocal, diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 9c27f9c9..0e810929 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -79,7 +79,6 @@ import ( "code.vikunja.io/web" "code.vikunja.io/web/handler" - "github.com/asaskevich/govalidator" "github.com/getsentry/sentry-go" sentryecho "github.com/getsentry/sentry-go/echo" "github.com/golang-jwt/jwt/v4" @@ -88,31 +87,6 @@ import ( elog "github.com/labstack/gommon/log" ) -// CustomValidator is a dummy struct to use govalidator with echo -type CustomValidator struct{} - -// Validate validates stuff -func (cv *CustomValidator) Validate(i interface{}) error { - if _, err := govalidator.ValidateStruct(i); err != nil { - - var errs []string - for field, e := range govalidator.ErrorsByField(err) { - errs = append(errs, field+": "+e) - } - - httperr := models.ValidationHTTPError{ - HTTPError: web.HTTPError{ - Code: models.ErrCodeInvalidData, - Message: "Invalid Data", - }, - InvalidFields: errs, - } - - return httperr - } - return nil -} - // NewEcho registers a new Echo instance func NewEcho() *echo.Echo { e := echo.New() diff --git a/pkg/routes/validation.go b/pkg/routes/validation.go new file mode 100644 index 00000000..38de55db --- /dev/null +++ b/pkg/routes/validation.go @@ -0,0 +1,55 @@ +// 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 routes + +import ( + "code.vikunja.io/api/pkg/models" + + "code.vikunja.io/web" + "github.com/asaskevich/govalidator" +) + +// CustomValidator is a dummy struct to use govalidator with echo +type CustomValidator struct{} + +func init() { + govalidator.TagMap["time"] = govalidator.Validator(func(str string) bool { + return govalidator.IsTime(str, "15:04") + }) +} + +// Validate validates stuff +func (cv *CustomValidator) Validate(i interface{}) error { + if _, err := govalidator.ValidateStruct(i); err != nil { + + var errs []string + for field, e := range govalidator.ErrorsByField(err) { + errs = append(errs, field+": "+e) + } + + httperr := models.ValidationHTTPError{ + HTTPError: web.HTTPError{ + Code: models.ErrCodeInvalidData, + Message: "Invalid Data", + }, + InvalidFields: errs, + } + + return httperr + } + return nil +} diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 5e8c1494..d7a0e6b3 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -9184,6 +9184,10 @@ const docTemplate = `{ "description": "If enabled, the user will get an email for their overdue tasks each morning.", "type": "boolean" }, + "overdue_tasks_reminders_time": { + "description": "The time when the daily summary of overdue tasks will be sent via email.", + "type": "string" + }, "timezone": { "description": "The user's time zone. Used to send task reminders in the time zone of the user.", "type": "string" diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index c4f34237..19a08cea 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -9175,6 +9175,10 @@ "description": "If enabled, the user will get an email for their overdue tasks each morning.", "type": "boolean" }, + "overdue_tasks_reminders_time": { + "description": "The time when the daily summary of overdue tasks will be sent via email.", + "type": "string" + }, "timezone": { "description": "The user's time zone. Used to send task reminders in the time zone of the user.", "type": "string" diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index a8ec87d0..c5cb34ce 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1310,6 +1310,10 @@ definitions: description: If enabled, the user will get an email for their overdue tasks each morning. type: boolean + overdue_tasks_reminders_time: + description: The time when the daily summary of overdue tasks will be sent + via email. + type: string timezone: description: The user's time zone. Used to send task reminders in the time zone of the user. diff --git a/pkg/user/user.go b/pkg/user/user.go index 7be296e9..802fb27c 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -94,6 +94,7 @@ type User struct { DiscoverableByName bool `xorm:"bool default false index" json:"-"` DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` OverdueTasksRemindersEnabled bool `xorm:"bool default true index" json:"-"` + OverdueTasksRemindersTime string `xorm:"varchar(5) not null default '09:00'" json:"-"` DefaultListID int64 `xorm:"bigint null index" json:"-"` WeekStart int `xorm:"null" json:"-"` Language string `xorm:"varchar(50) null" json:"-"` @@ -493,6 +494,7 @@ func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) { "week_start", "language", "timezone", + "overdue_tasks_reminders_time", ). Update(user) if err != nil { diff --git a/pkg/utils/time.go b/pkg/utils/time.go index dbd2f3cb..00e3fb6e 100644 --- a/pkg/utils/time.go +++ b/pkg/utils/time.go @@ -30,3 +30,12 @@ func GetTimeWithoutNanoSeconds(t time.Time) time.Time { // 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) } + +// GetTimeWithoutSeconds returns a time.Time with the seconds set to 0. +func GetTimeWithoutSeconds(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(), 0, 0, t.Location()).In(tz) +}