From c9ec6c9f6cbe75596972f39d5c516e7a7cce5335 Mon Sep 17 00:00:00 2001
From: meskio <meskio@sindominio.net>
Date: Fri, 9 Apr 2021 19:14:59 +0200
Subject: [PATCH] Implement notifications email/desktop

* Closes: #24
---
 api/api.go                             |   3 +
 api/auth.go                            |   1 +
 api/db/db.go                           |   6 +-
 api/db/db_test.go                      |  35 ++++++++
 api/db/notifications.go                |  87 +++++++++++++++++++
 api/db/notifications_test.go           | 113 +++++++++++++++++++++++++
 api/db/preferences.go                  |  59 +++++++++++++
 api/db/preferences_test.go             |  53 ++++++++++++
 api/mail.go                            |  47 ++++++++++
 api/member.go                          |  37 ++++++++
 api/member_test.go                     |  76 +++++++++++++++++
 api/order.go                           |   8 ++
 src/App.js                             |  10 ++-
 src/Head.js                            |   5 +-
 src/Panel.js                           |  12 ++-
 src/notifications.js                   |  41 ++++++++-
 src/preferences/Notifications.js       |  71 ++++++++++++++++
 src/{ => preferences}/OwnPassword.js   |   2 +-
 src/{ => preferences}/PasswordForm.js  |   0
 src/{ => preferences}/ResetPassword.js |   4 +-
 src/{ => preferences}/ResetRequest.js  |   2 +-
 21 files changed, 653 insertions(+), 19 deletions(-)
 create mode 100644 api/db/db_test.go
 create mode 100644 api/db/notifications.go
 create mode 100644 api/db/notifications_test.go
 create mode 100644 api/db/preferences.go
 create mode 100644 api/db/preferences_test.go
 create mode 100644 src/preferences/Notifications.js
 rename src/{ => preferences}/OwnPassword.js (97%)
 rename src/{ => preferences}/PasswordForm.js (100%)
 rename src/{ => preferences}/ResetPassword.js (97%)
 rename src/{ => preferences}/ResetRequest.js (97%)

diff --git a/api/api.go b/api/api.go
index ae38e00..9e62f1f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -44,6 +44,9 @@ func Init(dbPath string, signKey string, mail *Mail, r *mux.Router) error {
 	r.HandleFunc("/member/{num:[0-9]+}/transactions", a.authAdmin(a.GetMemberTransactions)).Methods("GET")
 	r.HandleFunc("/member/{num:[0-9]+}/purchase", a.authAdminNum(a.AddMemberPurchase)).Methods("POST")
 
+	r.HandleFunc("/preferences", a.authNum(a.GetPreferences)).Methods("GET")
+	r.HandleFunc("/preferences", a.authNum(a.SetPreferences)).Methods("PUT")
+
 	r.HandleFunc("/product", a.auth(a.ListProducts)).Methods("GET")
 	r.HandleFunc("/product", a.authAdmin(a.AddProduct)).Methods("POST")
 	r.HandleFunc("/product/{code:[0-9]+}", a.auth(a.GetProduct)).Methods("GET")
diff --git a/api/auth.go b/api/auth.go
index 5937107..7027ffa 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -95,6 +95,7 @@ func (a *api) GetUpdates(w http.ResponseWriter, req *http.Request) {
 
 	data := make(map[string]interface{})
 	data["role"] = member.Role
+	data["notifications"] = a.db.GetNotifications(num)
 	data["token"] = token // FIXME: just for backward compatibility, remove soon
 
 	if member.Role != roleClaim || tokenNeedsRenew(claims) {
diff --git a/api/db/db.go b/api/db/db.go
index d7752f5..2aef2af 100644
--- a/api/db/db.go
+++ b/api/db/db.go
@@ -15,8 +15,8 @@ func Init(dbPath string) (*DB, error) {
 		return nil, err
 	}
 
-	db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{},
-		&OrderPurchase{}, &Order{}, &PasswordReset{}, &Supplier{},
-		&Inventary{}, &InventaryProduct{})
+	db.AutoMigrate(&Member{}, &Preferences{}, &Product{}, &Purchase{}, &Topup{},
+		&Transaction{}, &OrderPurchase{}, &Order{}, &PasswordReset{},
+		&Supplier{}, &Inventary{}, &InventaryProduct{}, &Notification{})
 	return &DB{db}, err
 }
diff --git a/api/db/db_test.go b/api/db/db_test.go
new file mode 100644
index 0000000..8fdb909
--- /dev/null
+++ b/api/db/db_test.go
@@ -0,0 +1,35 @@
+package db
+
+import (
+	"io/ioutil"
+	"os"
+	"path"
+)
+
+var member = Member{
+	Num:   10,
+	Email: "foo@example.com",
+}
+
+func initTests() (*DB, func(), error) {
+	testPath, err := ioutil.TempDir(os.TempDir(), "cicer-test-")
+	if err != nil {
+		return nil, nil, err
+	}
+
+	closeFn := func() {
+		os.RemoveAll(testPath)
+	}
+
+	dbPath := path.Join(testPath, "test.db")
+	db, err := Init(dbPath)
+	if err != nil {
+		closeFn()
+		return nil, nil, err
+	}
+
+	_, err = db.AddMember(&MemberReq{
+		Member: member,
+	})
+	return db, closeFn, err
+}
diff --git a/api/db/notifications.go b/api/db/notifications.go
new file mode 100644
index 0000000..1c956b9
--- /dev/null
+++ b/api/db/notifications.go
@@ -0,0 +1,87 @@
+package db
+
+import (
+	"log"
+	"sort"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+)
+
+const (
+	maxNotifications = 5
+)
+
+type Notification struct {
+	gorm.Model
+	MemberNum int     `gorm:"column:member"`
+	Member    *Member `gorm:"foreignKey:MemberNum;references:Num"`
+	Type      string  `json:"type"`
+	Order     *Order  `json:"order,omitempty"`
+	OrderID   *uint   `json:"-"`
+}
+
+func (d *DB) AddOrderNotification(order Order) {
+	nums, err := d.listDesktopNotifications()
+	if err != nil {
+		log.Println("Error listing desktop notification recipients:", err)
+		return
+	}
+
+	for _, num := range nums {
+		if num == order.MemberNum {
+			continue
+		}
+
+		err = d.addNotification(&Notification{
+			MemberNum: num,
+			Type:      "order",
+			Order:     &order,
+		})
+		if err != nil {
+			log.Println("Error adding order", order.ID, "notification for", num, ":", err)
+		}
+	}
+}
+
+func (d *DB) addNotification(notification *Notification) error {
+	var notifications []Notification
+	err := d.db.Where("member = ?", notification.MemberNum).Find(&notifications).Error
+	if err != nil {
+		return err
+	}
+
+	if len(notifications) >= maxNotifications {
+		sort.Slice(notifications, func(i, j int) bool {
+			return notifications[i].CreatedAt.Before(notifications[j].CreatedAt)
+		})
+
+		for i := 0; i <= len(notifications)-maxNotifications; i++ {
+			err = d.db.Delete(notifications[i]).Error
+			if err != nil {
+				log.Println("Error deleting notification", notifications[i].ID, ":", err)
+			}
+		}
+	}
+	return d.db.Create(notification).Error
+}
+
+func (d *DB) GetNotifications(num int) []Notification {
+	var notifications []Notification
+	err := d.db.Where("member = ?", num).
+		Preload(clause.Associations).
+		Find(&notifications).Error
+	if err != nil {
+		log.Println("Error getting notifications:", err)
+		return notifications
+	}
+
+	if len(notifications) != 0 {
+		err = d.db.Where("member = ?", num).Delete(Notification{}).Error
+		if err != nil {
+			log.Println("Error flushing notifications:", err)
+		}
+	}
+
+	return notifications
+}
diff --git a/api/db/notifications_test.go b/api/db/notifications_test.go
new file mode 100644
index 0000000..7fc314d
--- /dev/null
+++ b/api/db/notifications_test.go
@@ -0,0 +1,113 @@
+package db
+
+import (
+	"testing"
+)
+
+func TestOrderNotification(t *testing.T) {
+	db, closeFn, err := initTests()
+	if err != nil {
+		t.Fatal("Init DB error:", err)
+	}
+	defer closeFn()
+
+	var order Order
+	order.MemberNum = member.Num + 1
+	err = db.AddOrder(&order)
+	if err != nil {
+		t.Fatal("Error creating order:", err)
+	}
+
+	notifications := db.GetNotifications(member.Num)
+	if len(notifications) != 0 {
+		t.Error("There are notifications:", notifications)
+	}
+	db.AddOrderNotification(order)
+	notifications = db.GetNotifications(member.Num)
+	if len(notifications) != 1 {
+		t.Error("Wrong nubmer of notifications:", notifications)
+	}
+	notifications = db.GetNotifications(member.Num)
+	if len(notifications) != 0 {
+		t.Error("There are notifications:", notifications)
+	}
+}
+
+func TestMaxNotifications(t *testing.T) {
+	db, closeFn, err := initTests()
+	if err != nil {
+		t.Fatal("Init DB error:", err)
+	}
+	defer closeFn()
+
+	var order Order
+	order.MemberNum = member.Num + 1
+	err = db.AddOrder(&order)
+	if err != nil {
+		t.Fatal("Error creating order:", err)
+	}
+
+	for i := 0; i < maxNotifications+2; i++ {
+		db.AddOrderNotification(order)
+	}
+	notifications := db.GetNotifications(member.Num)
+	if len(notifications) != maxNotifications {
+		t.Error("Wrong nubmer of notifications:", len(notifications), notifications)
+	}
+}
+
+func TestNotSelfOrderNotificatino(t *testing.T) {
+	db, closeFn, err := initTests()
+	if err != nil {
+		t.Fatal("Init DB error:", err)
+	}
+	defer closeFn()
+
+	var order Order
+	order.MemberNum = member.Num
+	err = db.AddOrder(&order)
+	if err != nil {
+		t.Fatal("Error creating order:", err)
+	}
+
+	db.AddOrderNotification(order)
+	notifications := db.GetNotifications(member.Num)
+	if len(notifications) != 0 {
+		t.Error("There are notifications:", notifications)
+	}
+}
+
+func TestDisabledNotifications(t *testing.T) {
+	db, closeFn, err := initTests()
+	if err != nil {
+		t.Fatal("Init DB error:", err)
+	}
+	defer closeFn()
+
+	var order Order
+	order.MemberNum = member.Num + 1
+	err = db.AddOrder(&order)
+	if err != nil {
+		t.Fatal("Error creating order:", err)
+	}
+
+	err = db.SetPreferences(member.Num, "disable_desktop_notifications", true)
+	if err != nil {
+		t.Fatal("Can't set preferences:", err)
+	}
+	db.AddOrderNotification(order)
+	notifications := db.GetNotifications(member.Num)
+	if len(notifications) != 0 {
+		t.Error("There are notifications:", notifications)
+	}
+
+	err = db.SetPreferences(member.Num, "disable_desktop_notifications", false)
+	if err != nil {
+		t.Fatal("Can't set preferences:", err)
+	}
+	db.AddOrderNotification(order)
+	notifications = db.GetNotifications(member.Num)
+	if len(notifications) != 1 {
+		t.Error("Wrong nubmer of notifications:", len(notifications), notifications)
+	}
+}
diff --git a/api/db/preferences.go b/api/db/preferences.go
new file mode 100644
index 0000000..6c6e88d
--- /dev/null
+++ b/api/db/preferences.go
@@ -0,0 +1,59 @@
+package db
+
+import (
+	"errors"
+
+	"gorm.io/gorm"
+)
+
+type Preferences struct {
+	gorm.Model
+	MemberNum int     `gorm:"column:member"`
+	Member    *Member `gorm:"foreignKey:MemberNum;references:Num"`
+
+	DisableEmailNotifications   bool `json:"disable_email_notifications"`
+	DisableDesktopNotifications bool `json:"disable_desktop_notifications"`
+}
+
+func (d DB) GetPreferences(num int) (preferences Preferences, err error) {
+	err = d.db.Where("member = ?", num).First(&preferences).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		err = nil
+	}
+	return
+}
+
+func (d DB) SetPreferences(num int, column string, value bool) error {
+	var preferences Preferences
+	err := d.db.Where("member = ?", num).First(&preferences).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		preferences.MemberNum = num
+		switch column {
+		case "disable_email_notifications":
+			preferences.DisableEmailNotifications = value
+		case "disable_desktop_notifications":
+			preferences.DisableDesktopNotifications = value
+		}
+		return d.db.Create(&preferences).Error
+	}
+
+	return d.db.Model(&preferences).Where("member = ?", num).Update(column, value).Error
+}
+
+func (d DB) ListNotificationEmails() (emails []string, err error) {
+	err = d.db.Model(&Member{}).
+		Select("email").
+		Joins("left join preferences on preferences.member = members.num").
+		Where("disable_email_notifications = false OR preferences.id IS NULL").
+		Find(&emails).Error
+	return
+}
+
+func (d DB) listDesktopNotifications() (nums []int, err error) {
+	err = d.db.Model(&Member{}).
+		Select("num").
+		Joins("left join preferences on preferences.member = members.num").
+		Where("disable_desktop_notifications = false OR preferences.id IS NULL").
+		Find(&nums).Error
+	return
+}
diff --git a/api/db/preferences_test.go b/api/db/preferences_test.go
new file mode 100644
index 0000000..30b797e
--- /dev/null
+++ b/api/db/preferences_test.go
@@ -0,0 +1,53 @@
+package db
+
+import (
+	"testing"
+)
+
+func TestListNotficationEmails(t *testing.T) {
+	db, closeFn, err := initTests()
+	if err != nil {
+		t.Fatal("Init DB error:", err)
+	}
+	defer closeFn()
+
+	emails, err := db.ListNotificationEmails()
+	if err != nil {
+		t.Fatal("Can't list emails:", err)
+	}
+	if len(emails) != 1 {
+		t.Fatal("Unexpected number of emails:", emails)
+	}
+	if emails[0] != member.Email {
+		t.Error("Wrong email:", emails[0])
+	}
+
+	err = db.SetPreferences(member.Num, "disable_email_notifications", true)
+	if err != nil {
+		t.Fatal("Can't set preferences:", err)
+	}
+
+	emails, err = db.ListNotificationEmails()
+	if err != nil {
+		t.Fatal("Can't list emails:", err)
+	}
+	if len(emails) != 0 {
+		t.Fatal("Unexpected number of emails:", emails)
+	}
+
+	err = db.SetPreferences(member.Num, "disable_email_notifications", false)
+	if err != nil {
+		t.Fatal("Can't set preferences:", err)
+	}
+
+	emails, err = db.ListNotificationEmails()
+	if err != nil {
+		t.Fatal("Can't list emails:", err)
+	}
+	if len(emails) != 1 {
+		t.Fatal("Unexpected number of emails:", emails)
+	}
+	if emails[0] != member.Email {
+		t.Error("Wrong email:", emails[0])
+	}
+}
diff --git a/api/mail.go b/api/mail.go
index 93f8bcc..61e50b8 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -2,6 +2,8 @@ package api
 
 import (
 	"bytes"
+	"fmt"
+	"log"
 	"net/smtp"
 	"strings"
 	"text/template"
@@ -28,6 +30,24 @@ Las siguientes personas han pedido:
     * {{.OrderProduct.Product.Name}}: {{.Amount}}{{end}}
 {{end}}
 
+Salud y garbancicos.
+`
+	orderNotifTmpl = `To: {{.To}}
+From: {{.From}}
+Content-Type: text/plain; charset="utf-8"
+Subject: [garbanzo] abierto pedido de {{.Order.Name}}
+
+Se acaba de abrir el pedido de {{.Order.Name}}.
+{{if .Order.Description}}
+"""
+{{.Order.Description}}
+"""
+{{end}}
+El pedido va a estar abierto hasta:
+{{.Order.Deadline}}
+
+{{.Link}}
+
 Salud y garbancicos.
 `
 	passwordResetTmpl = `To: {{.To}}
@@ -74,6 +94,7 @@ func NewMail(email, password, server, baseURL string) *Mail {
 	hostname := strings.Split(server, ":")[0]
 	username := strings.Split(email, "@")[0]
 	tmpl := template.Must(template.New("order").Parse(orderTmpl))
+	template.Must(tmpl.New("order_notif").Parse(orderNotifTmpl))
 	template.Must(tmpl.New("password_reset").Parse(passwordResetTmpl))
 	template.Must(tmpl.New("new_member").Parse(newMemberTmpl))
 
@@ -130,6 +151,32 @@ func (m Mail) sendOrder(to string, order *db.Order) error {
 	return smtp.SendMail(m.server, m.auth, m.email, []string{to}, buff.Bytes())
 }
 
+func (m Mail) sendOrderNotification(order db.Order, emails []string) {
+	var data struct {
+		To    string
+		From  string
+		Order *db.Order
+		Link  string
+	}
+	data.From = m.email
+	data.Order = &order
+	data.Link = fmt.Sprintf("%s/order/%d", m.baseURL, order.ID)
+
+	for _, to := range emails {
+		data.To = to
+		var buff bytes.Buffer
+		err := m.tmpl.ExecuteTemplate(&buff, "order_notif", data)
+		if err != nil {
+			log.Printf("Error templating emails order notification: %v", err)
+			continue
+		}
+		err = smtp.SendMail(m.server, m.auth, m.email, []string{to}, buff.Bytes())
+		if err != nil {
+			log.Printf("Error error sending emails order notification: %v", err)
+		}
+	}
+}
+
 type passwordResetData struct {
 	To   string
 	From string
diff --git a/api/member.go b/api/member.go
index 455f9ac..e4db581 100644
--- a/api/member.go
+++ b/api/member.go
@@ -178,3 +178,40 @@ func (a *api) UpdateMemberMe(num int, w http.ResponseWriter, req *http.Request)
 		return
 	}
 }
+
+func (a *api) GetPreferences(num int, w http.ResponseWriter, req *http.Request) {
+	preferences, err := a.db.GetPreferences(num)
+	if err != nil {
+		log.Printf("Can't get preferences %d: %v", num, err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	err = json.NewEncoder(w).Encode(preferences)
+	if err != nil {
+		log.Printf("Can't encode preferences: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+}
+
+func (a *api) SetPreferences(num int, w http.ResponseWriter, req *http.Request) {
+	var preferences map[string]bool
+	err := json.NewDecoder(req.Body).Decode(&preferences)
+	if err != nil {
+		log.Printf("Can't decode preferences: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	for column, value := range preferences {
+		err = a.db.SetPreferences(num, column, value)
+		if err != nil {
+			log.Printf("Can't update preference %s: %v", column, err)
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+	}
+	w.WriteHeader(http.StatusAccepted)
+}
diff --git a/api/member_test.go b/api/member_test.go
index 10db059..7c11c90 100644
--- a/api/member_test.go
+++ b/api/member_test.go
@@ -160,6 +160,82 @@ func TestMemberUpdateMe(t *testing.T) {
 	}
 }
 
+func TestEmptyPreferences(t *testing.T) {
+	tapi := newTestAPI(t)
+	defer tapi.close()
+	tapi.addTestMember()
+
+	var preferences db.Preferences
+	resp := tapi.do("GET", "/preferences", nil, &preferences)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatal("Can't get preferences:", resp.Status)
+	}
+	if preferences.DisableDesktopNotifications {
+		t.Error("Unexpected desktop notification value")
+	}
+	if preferences.DisableEmailNotifications {
+		t.Error("Unexpected email notification value")
+	}
+}
+
+func TestSetPreferences(t *testing.T) {
+	tapi := newTestAPI(t)
+	defer tapi.close()
+	tapi.addTestMember()
+
+	prefs := map[string]bool{"disable_email_notifications": true}
+	resp := tapi.do("PUT", "/preferences", &prefs, nil)
+	if resp.StatusCode != http.StatusAccepted {
+		t.Fatal("Can't update preferences:", resp.Status)
+	}
+
+	var preferences db.Preferences
+	resp = tapi.do("GET", "/preferences", nil, &preferences)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatal("Can't get preferences:", resp.Status)
+	}
+	if preferences.DisableDesktopNotifications {
+		t.Error("Unexpected desktop notification value")
+	}
+	if !preferences.DisableEmailNotifications {
+		t.Error("Unexpected email notification value")
+	}
+
+	prefs = map[string]bool{"disable_desktop_notifications": true}
+	resp = tapi.do("PUT", "/preferences", &prefs, nil)
+	if resp.StatusCode != http.StatusAccepted {
+		t.Fatal("Can't update preferences:", resp.Status)
+	}
+
+	resp = tapi.do("GET", "/preferences", nil, &preferences)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatal("Can't get preferences:", resp.Status)
+	}
+	if !preferences.DisableDesktopNotifications {
+		t.Error("Unexpected desktop notification value")
+	}
+	if !preferences.DisableEmailNotifications {
+		t.Error("Unexpected email notification value")
+	}
+
+	prefs = map[string]bool{"disable_email_notifications": false}
+	resp = tapi.do("PUT", "/preferences", &prefs, nil)
+	if resp.StatusCode != http.StatusAccepted {
+		t.Fatal("Can't update preferences:", resp.Status)
+	}
+
+	resp = tapi.do("GET", "/preferences", nil, &preferences)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatal("Can't get preferences:", resp.Status)
+	}
+	if !preferences.DisableDesktopNotifications {
+		t.Error("Unexpected desktop notification value")
+	}
+	if preferences.DisableEmailNotifications {
+		t.Error("Unexpected email notification value")
+	}
+}
+
 func (tapi *testAPI) addTestMember() {
 	resp := tapi.doAdmin("POST", "/member", testMember, nil)
 	if resp.StatusCode != http.StatusCreated {
diff --git a/api/order.go b/api/order.go
index 259aa47..62a8a65 100644
--- a/api/order.go
+++ b/api/order.go
@@ -144,6 +144,14 @@ func (a *api) AddOrder(num int, w http.ResponseWriter, req *http.Request) {
 		return
 	}
 
+	a.db.AddOrderNotification(order)
+	emails, err := a.db.ListNotificationEmails()
+	if err != nil {
+		log.Printf("Error listing emails to send order notification: %v", err)
+	} else {
+		a.mail.sendOrderNotification(order, emails)
+	}
+
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(http.StatusCreated)
 	err = json.NewEncoder(w).Encode(order)
diff --git a/src/App.js b/src/App.js
index 5cb9bf2..88c18c4 100644
--- a/src/App.js
+++ b/src/App.js
@@ -2,6 +2,7 @@ import React, { useEffect } from "react";
 import { useStorageItem } from "@capacitor-community/react-hooks/storage";
 import Panel from "./Panel";
 import AuthContext from "./AuthContext";
+import notify from "./notifications";
 import { ResponseError } from "./errors";
 import { url } from "./util";
 
@@ -18,13 +19,16 @@ function App() {
       if (data.role !== undefined) {
         setRole(data.role);
       }
+      if (data.notifications) {
+        data.notifications.forEach((n) => notify(n));
+      }
     };
 
-    const timerID = window.setInterval(
+    const timerID = setInterval(
       () => getUpdates(token, setUpdatesData),
-      60000 // every minute
+      6000 // every minute
     );
-    return () => window.clearInterval(timerID);
+    return () => clearInterval(timerID);
   }, [token, setToken, setRole]);
 
   const login = (newToken, member) => {
diff --git a/src/Head.js b/src/Head.js
index 9934e1f..6c89c01 100644
--- a/src/Head.js
+++ b/src/Head.js
@@ -99,9 +99,12 @@ function Head(props) {
 
         <Nav className="ml-auto" activeKey={location.pathname}>
           <NavDropdown title="Ajustes" id="ajustes">
-            <LinkContainer to="/password">
+            <LinkContainer to="/preferences/password">
               <NavDropdown.Item>Cambiar contraseƱa</NavDropdown.Item>
             </LinkContainer>
+            <LinkContainer to="/preferences/notifications">
+              <NavDropdown.Item>Notificaciones</NavDropdown.Item>
+            </LinkContainer>
           </NavDropdown>
 
           {adminNav}
diff --git a/src/Panel.js b/src/Panel.js
index 9a7e68f..8cb864e 100644
--- a/src/Panel.js
+++ b/src/Panel.js
@@ -11,7 +11,8 @@ import Inventary from "./inventary/Inventary";
 import ShowInventary from "./inventary/ShowInventary";
 import CreateSupplier from "./inventary/CreateSupplier";
 import Dashboard from "./Dashboard";
-import OwnPassword from "./OwnPassword";
+import OwnPassword from "./preferences/OwnPassword";
+import Notifications from "./preferences/Notifications";
 import Purchase from "./purchase/Purchase";
 import Topup from "./Topup";
 import ShowTransaction from "./transaction/ShowTransaction";
@@ -21,8 +22,8 @@ import CreateOrder from "./order/CreateOrder";
 import OrderList from "./order/OrderList";
 import EditOrder from "./order/EditOrder";
 import SignIn from "./SignIn";
-import ResetRequest from "./ResetRequest";
-import ResetPassword from "./ResetPassword";
+import ResetRequest from "./preferences/ResetRequest";
+import ResetPassword from "./preferences/ResetPassword";
 import Head from "./Head";
 import logo from "./logo.svg";
 
@@ -76,9 +77,12 @@ function LogedPanel(props) {
             <Route path="/transaction">
               <TransactionList />
             </Route>
-            <Route path="/password">
+            <Route path="/preferences/password">
               <OwnPassword />
             </Route>
+            <Route path="/preferences/notifications">
+              <Notifications />
+            </Route>
             <Route path="/purchase">
               <Purchase />
             </Route>
diff --git a/src/notifications.js b/src/notifications.js
index 8f1ad92..eb82fa0 100644
--- a/src/notifications.js
+++ b/src/notifications.js
@@ -1,13 +1,46 @@
 import { Plugins } from "@capacitor/core";
+import { useHistory } from "react-router-dom";
+
 const { LocalNotifications } = Plugins;
+LocalNotifications.addListener("localNotificationActionPerformed", (action) => {
+  const history = useHistory();
+  const splited_id = action.notification.id.split("-");
+  if (splited_id.length !== 2) {
+    return;
+  }
+
+  const type = splited_id[0];
+  const id = splited_id[1];
+  switch (type) {
+    case "order":
+      history.push("/order/" + id);
+      break;
+    default:
+      return;
+  }
+});
+
+function notify(notification) {
+  let title = "";
+  let body = "";
+  let id = "";
+
+  switch (notification.type) {
+    case "order":
+      title = "Pedido de " + notification.order.name;
+      body = notification.order.description;
+      id = "order-" + notification.order.ID;
+      break;
+    default:
+      return;
+  }
 
-function notify(text) {
   LocalNotifications.schedule({
     notifications: [
       {
-        title: "Hello",
-        body: text,
-        id: Date.now(),
+        title,
+        body,
+        id,
         smallIcon: "@mipmap/ic_launcher",
         sound: null,
         attachments: null,
diff --git a/src/preferences/Notifications.js b/src/preferences/Notifications.js
new file mode 100644
index 0000000..7b8944d
--- /dev/null
+++ b/src/preferences/Notifications.js
@@ -0,0 +1,71 @@
+import React, { useState, useContext } from "react";
+import { Form } from "react-bootstrap";
+import Fetcher from "../Fetcher";
+import AuthContext from "../AuthContext";
+import { url } from "../util";
+
+function send(column, value, token) {
+  let bodyObj = {};
+  bodyObj[column] = value;
+  const body = JSON.stringify(bodyObj);
+
+  fetch(url("/api/preferences"), {
+    headers: { "x-authentication": token },
+    method: "PUT",
+    body,
+  }).then((response) => {
+    if (!response.ok) {
+      console.log(
+        "Failed to send preference: " +
+          response.status.toString() +
+          " " +
+          response.statusText
+      );
+    }
+  });
+}
+
+function OwnPassword() {
+  const auth = useContext(AuthContext);
+  const [email, setEmail] = useState(true);
+  const [desktop, setDesktop] = useState(true);
+
+  const setPreferences = (p) => {
+    setEmail(!p.disable_email_notifications);
+    setDesktop(!p.disable_desktop_notifications);
+  };
+
+  const sendDesktop = (value) => {
+    console.log("send desktop");
+    send("disable_desktop_notifications", !value, auth.token);
+    setDesktop(value);
+  };
+  const sendEmail = (value) => {
+    send("disable_email_notifications", !value, auth.token);
+    setEmail(value);
+  };
+
+  return (
+    <Fetcher url="/api/preferences" onFetch={setPreferences}>
+      <h2 className="text-center">Ajustes de notificaciones</h2>
+      <Form>
+        <Form.Check
+          id="email"
+          type="switch"
+          label="Recivir notificaciones por email"
+          onChange={(e) => sendEmail(e.target.checked)}
+          checked={email}
+        />
+        <Form.Check
+          id="desktop"
+          type="switch"
+          label="Recivir notificaciones en la app"
+          onChange={(e) => sendDesktop(e.target.checked)}
+          checked={desktop}
+        />
+      </Form>
+    </Fetcher>
+  );
+}
+
+export default OwnPassword;
diff --git a/src/OwnPassword.js b/src/preferences/OwnPassword.js
similarity index 97%
rename from src/OwnPassword.js
rename to src/preferences/OwnPassword.js
index 9d6c899..433a782 100644
--- a/src/OwnPassword.js
+++ b/src/preferences/OwnPassword.js
@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 import { Form, Col, Row, Button, Alert } from "react-bootstrap";
 import PasswordForm from "./PasswordForm";
-import Sender from "./Sender";
+import Sender from "../Sender";
 
 function OwnPassword() {
   const [old_password, setOldPassword] = useState("");
diff --git a/src/PasswordForm.js b/src/preferences/PasswordForm.js
similarity index 100%
rename from src/PasswordForm.js
rename to src/preferences/PasswordForm.js
diff --git a/src/ResetPassword.js b/src/preferences/ResetPassword.js
similarity index 97%
rename from src/ResetPassword.js
rename to src/preferences/ResetPassword.js
index 4443b1a..8135117 100644
--- a/src/ResetPassword.js
+++ b/src/preferences/ResetPassword.js
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react";
 import { Link, useParams } from "react-router-dom";
 import { Alert, Form, Col, Row, Button, Spinner } from "react-bootstrap";
 import PasswordForm from "./PasswordForm";
-import Sender from "./Sender";
-import { url } from "./util";
+import Sender from "../Sender";
+import { url } from "../util";
 
 function ResetPassword() {
   const [password, setPassword] = useState("");
diff --git a/src/ResetRequest.js b/src/preferences/ResetRequest.js
similarity index 97%
rename from src/ResetRequest.js
rename to src/preferences/ResetRequest.js
index d69f54b..1ad371b 100644
--- a/src/ResetRequest.js
+++ b/src/preferences/ResetRequest.js
@@ -1,6 +1,6 @@
 import React, { useState } from "react";
 import { Row, Form, Button, Alert } from "react-bootstrap";
-import Sender from "./Sender";
+import Sender from "../Sender";
 
 function ResetRequest() {
   const [email, setEmail] = useState("");
-- 
GitLab