Browse Source

FEATURE: TODO nearing Due date email notifications

master
parent
commit
83291a95ea
  1. 31
      pages/profile.html
  2. 4
      scripts/api.js
  3. 3
      src/db/db.go
  4. 25
      src/db/todo.go
  5. 53
      src/db/user.go
  6. 45
      src/server/endpoints.go
  7. 99
      src/server/notifications.go
  8. 5
      src/server/server.go
  9. 10
      translations/ENG/profile.json
  10. 10
      translations/RU/profile.json

31
pages/profile.html

@ -14,6 +14,24 @@
<div class="flex-grow-1 ms-3"> <div class="flex-grow-1 ms-3">
<h5 class="mb-1">{{ .Data.Email }}</h5> <h5 class="mb-1">{{ .Data.Email }}</h5>
<p class="mb-2 pb-1">{{index .Translation "profile created"}}: {{ .Data.TimeCreated }}</p> <p class="mb-2 pb-1">{{index .Translation "profile created"}}: {{ .Data.TimeCreated }}</p>
<div class="d-flex justify-content-start rounded-3 p-2 mb-2 bg-body-tertiary">
<div>
<p class="small text-muted mb-1">{{index .Translation "profile option notify me"}}</p>
<p class="mb-0">
{{ if .Data.NotifyOnTodos }}
<label for="notify-me-checkbox" class="btn btn-primary">
{{index .Translation "profile checkbox notify me"}}
</label>
<input type="checkbox" class="btn-check" id="notify-me-checkbox" checked onclick="toggleNotifyOnTodos();">
{{ else }}
<label for="notify-me-checkbox" class="btn btn-outline-primary">
{{index .Translation "profile checkbox notify me"}}
</label>
<input type="checkbox" class="btn-check" id="notify-me-checkbox" onclick="toggleNotifyOnTodos();">
{{ end }}
</p>
</div>
</div>
<div class="d-flex pt-1"> <div class="d-flex pt-1">
<button type="button" class="btn btn-primary flex-grow-1" onclick="logOut();"> <button type="button" class="btn btn-primary flex-grow-1" onclick="logOut();">
{{index .Translation "profile log out"}} {{index .Translation "profile log out"}}
@ -69,6 +87,19 @@ async function handleAccountDelete() {
await deleteAccount(); await deleteAccount();
window.location.replace("/about"); window.location.replace("/about");
} }
async function toggleNotifyOnTodos() {
const toggleValue = document.getElementById("notify-me-checkbox").checked;
let response = await userSetNotify(toggleValue);
if (response.ok) {
window.location.reload();
} else {
console.log(await response.text());
}
}
</script> </script>
{{ end }} {{ end }}

4
scripts/api.js

@ -97,3 +97,7 @@ async function updateGroup(id, updatedGroup) {
async function updateUser(updatedUser) { async function updateUser(updatedUser) {
return update("/api/user/update", updatedUser); return update("/api/user/update", updatedUser);
} }
async function userSetNotify(value) {
return post("/api/user/notify", {"notify": Boolean(value)});
}

3
src/db/db.go

@ -36,7 +36,8 @@ func setUpTables(db *DB) error {
email TEXT PRIMARY KEY UNIQUE, email TEXT PRIMARY KEY UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
time_created_unix INTEGER, time_created_unix INTEGER,
confirmed_email INTEGER)`, confirmed_email INTEGER,
notify_on_todos INTEGER)`,
) )
if err != nil { if err != nil {
return err return err

25
src/db/todo.go

@ -286,3 +286,28 @@ func (db *DB) DoesUserOwnTodo(todoId uint64, email string) bool {
return true return true
} }
func (db *DB) GetUserTodosDue(userEmail string, tMinusSec uint64) ([]*Todo, error) {
now := time.Now().Unix()
rows, err := db.Query(
"SELECT * FROM todos WHERE (owner_email=? AND due_unix<=? AND NOT is_done AND due_unix>0)",
userEmail,
tMinusSec+uint64(now),
)
if err != nil {
return nil, err
}
defer rows.Close()
var todos []*Todo
for rows.Next() {
todo, err := scanTodo(rows)
if err != nil {
return nil, err
}
todos = append(todos, todo)
}
return todos, nil
}

53
src/db/user.go

@ -18,7 +18,9 @@
package db package db
import "database/sql" import (
"database/sql"
)
// User structure // User structure
type User struct { type User struct {
@ -27,12 +29,22 @@ type User struct {
TimeCreatedUnix uint64 `json:"timeCreatedUnix"` TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
TimeCreated string `json:"timeCreated"` TimeCreated string `json:"timeCreated"`
ConfirmedEmail bool `json:"confirmedEmail"` ConfirmedEmail bool `json:"confirmedEmail"`
NotifyOnTodos bool `json:"notifyOnTodos"`
}
func scanUserRaw(rows *sql.Rows) (*User, error) {
var user User
err := rows.Scan(&user.Email, &user.Password, &user.TimeCreatedUnix, &user.ConfirmedEmail, &user.NotifyOnTodos)
if err != nil {
return nil, err
}
return &user, nil
} }
func scanUser(rows *sql.Rows) (*User, error) { func scanUser(rows *sql.Rows) (*User, error) {
rows.Next() rows.Next()
var user User user, err := scanUserRaw(rows)
err := rows.Scan(&user.Email, &user.Password, &user.TimeCreatedUnix, &user.ConfirmedEmail)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -40,7 +52,7 @@ func scanUser(rows *sql.Rows) (*User, error) {
// Convert to Basic time string // Convert to Basic time string
user.TimeCreated = unixToTimeStr(user.TimeCreatedUnix) user.TimeCreated = unixToTimeStr(user.TimeCreatedUnix)
return &user, nil return user, nil
} }
// Searches for user with email and returns it // Searches for user with email and returns it
@ -62,11 +74,12 @@ func (db *DB) GetUser(email string) (*User, error) {
// Creates a new user in the database // Creates a new user in the database
func (db *DB) CreateUser(newUser User) error { func (db *DB) CreateUser(newUser User) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO users(email, password, time_created_unix, confirmed_email) VALUES(?, ?, ?, ?)", "INSERT INTO users(email, password, time_created_unix, confirmed_email, notify_on_todos) VALUES(?, ?, ?, ?, ?)",
newUser.Email, newUser.Email,
newUser.Password, newUser.Password,
newUser.TimeCreatedUnix, newUser.TimeCreatedUnix,
newUser.ConfirmedEmail, newUser.ConfirmedEmail,
newUser.NotifyOnTodos,
) )
return err return err
@ -82,19 +95,25 @@ func (db *DB) DeleteUser(email string) error {
return err return err
} }
// Updades user's email address, password, email confirmation with given email address // Updades user's email address, password, email confirmation and todo notification status with given email address
func (db *DB) UserUpdate(newUser User) error { func (db *DB) UserUpdate(newUser User) error {
_, err := db.Exec( _, err := db.Exec(
"UPDATE users SET email=?, password=?, confirmed_email=? WHERE email=?", "UPDATE users SET email=?, password=?, confirmed_email=?, notify_on_todos=? WHERE email=?",
newUser.Email, newUser.Email,
newUser.Password, newUser.Password,
newUser.ConfirmedEmail, newUser.ConfirmedEmail,
newUser.NotifyOnTodos,
newUser.Email, newUser.Email,
) )
return err return err
} }
func (db *DB) UserSetNotifyOnTodos(email string, value bool) error {
_, err := db.Exec("UPDATE users SET notify_on_todos=? WHERE email=?", value, email)
return err
}
// Deletes a user and all his TODOs (with groups) as well // Deletes a user and all his TODOs (with groups) as well
func (db *DB) DeleteUserClean(email string) error { func (db *DB) DeleteUserClean(email string) error {
err := db.DeleteAllUserTodoGroups(email) err := db.DeleteAllUserTodoGroups(email)
@ -143,3 +162,23 @@ func (db *DB) DeleteUnverifiedUserClean(email string) error {
return nil return nil
} }
func (db *DB) GetAllUsersWithNotificationsOn() ([]*User, error) {
rows, err := db.Query("SELECT * FROM users WHERE notify_on_todos=?", true)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*User
for rows.Next() {
user, err := scanUserRaw(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}

45
src/server/endpoints.go

@ -223,6 +223,51 @@ func (s *Server) EndpointUserVerify(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func (s *Server) EndpointUserNotify(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
type notifyRequest struct {
Notify bool `json:"notify"`
}
// Retrieve data
defer req.Body.Close()
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserNotify] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
var notifyResult notifyRequest
err = json.Unmarshal(contents, &notifyResult)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to unmarshal notification value change: %s", err)
http.Error(w, "Bad JSON", http.StatusInternalServerError)
return
}
userEmail := GetEmailFromReq(req)
err = s.db.UserSetNotifyOnTodos(userEmail, notifyResult.Notify)
if err != nil {
logger.Error("[Server][EndpointUserNotify] Failed to UserSetNotifyOnTodos for %s: %s", userEmail, err)
http.Error(w, "Failed to change user settings", http.StatusInternalServerError)
return
}
if notifyResult.Notify {
logger.Info("[Server][EndpointUserNotify] Notifying %s for due TODOs", userEmail)
} else {
logger.Info("[Server][EndpointUserNotify] Stopped notifying %s for due TODOs", userEmail)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserLogin(w http.ResponseWriter, req *http.Request) { func (s *Server) EndpointUserLogin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

99
src/server/notifications.go

@ -0,0 +1,99 @@
package server
import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/email"
"Unbewohnte/dela/logger"
"fmt"
"time"
)
type Notification struct {
UserEmail string
ToDo db.Todo
}
func (s *Server) SendTODOSNotification(userEmail string, todos []*db.Todo) error {
var err error
switch len(todos) {
case 0:
err = nil
case 1:
err = s.emailer.SendEmail(
email.NewEmail(
s.config.Verification.Emailer.User,
"Dela: TODO Notification",
fmt.Sprintf("<p>Notifying you on your \"%s\" TODO.</p><p>Due date is %s</p>", todos[0].Text, todos[0].Due),
[]string{userEmail},
),
)
default:
err = s.emailer.SendEmail(
email.NewEmail(
s.config.Verification.Emailer.User,
"Dela: TODO Notification",
fmt.Sprintf("<p>Notifying you on your \"%s\" TODO.</p><p>Due date is %s</p><p>There are also %d other TODOs nearing Due date.</p>", todos[0].Text, todos[0].Due, len(todos)-1),
[]string{userEmail},
),
)
}
return err
}
func (s *Server) NotifyUserOnTodos(userEmail string) error {
user, err := s.db.GetUser(userEmail)
if err != nil {
return err
}
if !user.NotifyOnTodos {
return nil
}
todosDue, err := s.db.GetUserTodosDue(userEmail, uint64(time.Duration(time.Hour*24).Seconds()))
if err != nil {
return err
}
if len(todosDue) == 0 {
return nil
}
logger.Info("[Server][Notifications Routine] Notifying %s with %d TODOs...", userEmail, len(todosDue))
err = s.SendTODOSNotification(userEmail, todosDue)
if err != nil {
return err
}
return nil
}
func (s *Server) StartNotificationsRoutine(delay time.Duration) {
logger.Info("[Server][Notifications Routine] Notifications Routine Started!")
var failed bool = false
for {
logger.Info("[Server][Notifications Routine] Retrieving list of users to be notified...")
users, err := s.db.GetAllUsersWithNotificationsOn()
if err != nil {
logger.Error("[Server][Notifications Routine] Failed to retrieve users with notification on: %s", err)
failed = true
}
if !failed {
for _, user := range users {
err = s.NotifyUserOnTodos(user.Email)
if err != nil {
logger.Error("[Server][Notifications routine] Failed to notify %s: %s", user.Email, err)
continue
}
}
}
failed = false
time.Sleep(delay)
}
}

5
src/server/server.go

@ -300,6 +300,7 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific
mux.HandleFunc("/api/user/login", server.EndpointUserLogin) // Non specific mux.HandleFunc("/api/user/login", server.EndpointUserLogin) // Non specific
mux.HandleFunc("/api/user/verify", server.EndpointUserVerify) // Non specific mux.HandleFunc("/api/user/verify", server.EndpointUserVerify) // Non specific
mux.HandleFunc("/api/user/notify", server.EndpointUserNotify) // Non specific
mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
@ -327,6 +328,10 @@ func New(config conf.Conf) (*Server, error) {
// Launches server instance // Launches server instance
func (s *Server) Start() error { func (s *Server) Start() error {
// Launch notifier routine
logger.Info("[Server] Starting Notifications Routine...")
go s.StartNotificationsRoutine(time.Hour * 24)
if s.config.Server.CertFilePath != "" && s.config.Server.KeyFilePath != "" { if s.config.Server.CertFilePath != "" && s.config.Server.KeyFilePath != "" {
logger.Info("[Server] Using TLS") logger.Info("[Server] Using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port) logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port)

10
translations/ENG/profile.json

@ -35,6 +35,16 @@
"id": "profile modal cancel button", "id": "profile modal cancel button",
"message": "Cancel", "message": "Cancel",
"translation": "Cancel" "translation": "Cancel"
},
{
"id": "profile option notify me",
"message": "Notify me on my TODOs",
"translation": "Notify me on my TODOs"
},
{
"id": "profile checkbox notify me",
"message": "Notify me",
"translation": "Notify me"
} }
] ]
} }

10
translations/RU/profile.json

@ -35,6 +35,16 @@
"id": "profile modal cancel button", "id": "profile modal cancel button",
"message": "Cancel", "message": "Cancel",
"translation": "Отменить" "translation": "Отменить"
},
{
"id": "profile option notify me",
"message": "Notify me on my TODOs",
"translation": "Оповещать меня о моих заметках"
},
{
"id": "profile checkbox notify me",
"message": "Notify me",
"translation": "Оповещать"
} }
] ]
} }
Loading…
Cancel
Save