Schedule and notification

This commit is contained in:
Magnus Åhall 2024-03-28 21:49:48 +01:00
parent fd01e751e2
commit e9ce21133a
7 changed files with 262 additions and 31 deletions

13
db.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
// Standard
"database/sql"
)
// Queryable can take both a Db and a transaction
type Queryable interface {
Exec(string, ...any) (sql.Result, error)
Query(string, ...any) (*sql.Rows, error)
QueryRow(string, ...any) *sql.Row
}

42
main.go
View File

@ -20,6 +20,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
) )
const LISTEN_HOST = "0.0.0.0" const LISTEN_HOST = "0.0.0.0"
@ -36,6 +37,7 @@ var (
static http.Handler static http.Handler
config Config config Config
logger *slog.Logger logger *slog.Logger
schedulers map[int]Schedule
VERSION string VERSION string
//go:embed version sql/* //go:embed version sql/*
@ -63,6 +65,8 @@ func init() { // {{{
opt.Level = slog.LevelDebug opt.Level = slog.LevelDebug
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt)) logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt))
schedulers = make(map[int]Schedule, 512)
configFilename := os.Getenv("HOME") + "/.config/notes.yaml" configFilename := os.Getenv("HOME") + "/.config/notes.yaml"
flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on") flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on")
flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists") flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists")
@ -122,45 +126,25 @@ func main() { // {{{
os.Exit(0) os.Exit(0)
} }
go scheduleHandler()
err = service.Start() err = service.Start()
if err != nil { if err != nil {
logger.Error("webserver", "error", err) logger.Error("webserver", "error", err)
os.Exit(1) os.Exit(1)
} }
} // }}} } // }}}
func scheduleHandler() { // {{{
// Wait for the approximate minute.
wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds()
time.Sleep(time.Millisecond * time.Duration(wait))
/* tick := time.NewTicker(time.Minute)
func userPassword(w http.ResponseWriter, r *http.Request) { // {{{ for {
var err error <-tick.C
var ok bool
var session Session
if session, _, err = ValidateSession(r, true); err != nil {
responseError(w, err)
return
} }
req := struct {
CurrentPassword string
NewPassword string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
ok, err = session.UpdatePassword(req.CurrentPassword, req.NewPassword)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"CurrentPasswordOK": ok,
})
} // }}} } // }}}
*/
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/node/tree") logger.Info("webserver", "request", "/node/tree")

72
node.go
View File

@ -6,6 +6,7 @@ import (
// Standard // Standard
"time" "time"
"database/sql"
) )
type ChecklistItem struct { type ChecklistItem struct {
@ -323,8 +324,69 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
return return
} // }}} } // }}}
func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
var scannedSchedules, dbSchedules, add, remove []Schedule
scannedSchedules = ScanForSchedules(content)
for i := range scannedSchedules {
scannedSchedules[i].Node.ID = nodeID
scannedSchedules[i].UserID = userID
}
var tsx *sql.Tx
tsx, err = service.Db.Conn.Begin()
if err != nil {
return
}
dbSchedules, err = RetrieveSchedules(userID, nodeID)
if err != nil {
tsx.Rollback()
return
}
for _, scanned := range scannedSchedules {
found := false
for _, db := range dbSchedules {
if scanned.IsEqual(db) {
found = true
break
}
}
if !found {
add = append(add, scanned)
}
}
for _, db := range dbSchedules {
found := false
for _, scanned := range scannedSchedules {
if db.IsEqual(scanned) {
found = true
break
}
}
if !found {
remove = append(remove, db)
}
}
for _, event := range remove {
err = event.Delete(tsx)
if err != nil {
tsx.Rollback()
return
}
}
for _, event := range add {
err = event.Insert(tsx)
if err != nil {
tsx.Rollback()
return
}
}
if cryptoKeyID > 0 { if cryptoKeyID > 0 {
_, err = service.Db.Conn.Exec(` _, err = tsx.Exec(`
UPDATE node UPDATE node
SET SET
content = '', content = '',
@ -345,7 +407,7 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
markdown, markdown,
) )
} else { } else {
_, err = service.Db.Conn.Exec(` _, err = tsx.Exec(`
UPDATE node UPDATE node
SET SET
content = $1, content = $1,
@ -366,6 +428,12 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
markdown, markdown,
) )
} }
if err != nil {
tsx.Rollback()
return
}
err = tsx.Commit()
return return
} // }}} } // }}}

140
schedule.go Normal file
View File

@ -0,0 +1,140 @@
package main
import (
// Standard
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
func init() {
fmt.Printf("")
}
type Schedule struct {
ID int
UserID int `json:"user_id"`
Node Node
ScheduleUUID string `db:"schedule_uuid"`
Time time.Time
Description string
Acknowledged bool
}
func ScanForSchedules(content string) (schedules []Schedule) {
schedules = []Schedule{}
rxp := regexp.MustCompile(`\{\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}(?::[0-9]{2})?)\s*\,\s*([^\]]+?)\s*\}`)
foundSchedules := rxp.FindAllStringSubmatch(content, -1)
for _, data := range foundSchedules {
// Missing seconds
if strings.Count(data[1], ":") == 1 {
data[1] = data[1] + ":00"
}
timestamp, err := time.Parse("2006-01-02 15:04:05", data[1])
if err != nil {
continue
}
schedule := Schedule{
Time: timestamp,
Description: data[2],
}
schedules = append(schedules, schedule)
}
return
}
func (a Schedule) IsEqual(b Schedule) bool {
return a.UserID == b.UserID &&
a.Node.ID == b.Node.ID &&
a.Time.Equal(b.Time) &&
a.Description == b.Description
}
func (s *Schedule) Insert(queryable Queryable) error {
res := queryable.QueryRow(`
INSERT INTO schedule(user_id, node_id, time, description)
VALUES($1, $2, $3, $4)
RETURNING id
`,
s.UserID,
s.Node.ID,
s.Time,
s.Description,
)
return res.Scan(&s.ID)
}
func (s *Schedule) Delete(queryable Queryable) error {
_, err := queryable.Exec(`
DELETE FROM schedule
WHERE
user_id = $1 AND
id = $2
`,
s.UserID,
s.ID,
)
return err
}
func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) {
schedules = []Schedule{}
res := service.Db.Conn.QueryRow(`
WITH schedule_events AS (
SELECT
id,
user_id,
json_build_object('id', node_id) AS node,
schedule_uuid,
time::timestamptz,
description,
acknowledged
FROM schedule
WHERE
user_id=$1 AND
CASE
WHEN $2 > 0 THEN node_id = $2
ELSE true
END
)
SELECT
COALESCE(jsonb_agg(s.*), '[]'::jsonb)
FROM schedule_events s
`,
userID,
nodeID,
)
var data []byte
err = res.Scan(&data)
if err != nil {
return
}
err = json.Unmarshal(data, &schedules)
return
}
func ExpiredSchedules() []Schedule {
schedules := []Schedule{}
res, err := service.Db.Conn.Query(`
SELECT
*
FROM schedule
WHERE
time < NOW() AND
NOT acknowledged
ORDER BY
time ASC
`)
return schedules
}

14
sql/00015.sql Normal file
View File

@ -0,0 +1,14 @@
CREATE TABLE public.schedule (
id SERIAL NOT NULL,
user_id INT4 NOT NULL,
node_id INT4 NOT NULL,
schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL,
"time" TIMESTAMP NOT NULL,
description VARCHAR DEFAULT '' NOT NULL,
acknowledged BOOL DEFAULT false NOT NULL,
CONSTRAINT schedule_pk PRIMARY KEY (id),
CONSTRAINT schedule_uuid UNIQUE (schedule_uuid),
CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);

1
sql/00016.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);

11
sql/00017.sql Normal file
View File

@ -0,0 +1,11 @@
CREATE TABLE public.notification (
id SERIAl NOT NULL,
user_id INT4 NOT NULL,
service VARCHAR DEFAULT 'NTFY' NOT NULL,
"configuration" JSONB DEFAULT '{}' NOT NULL,
prio INT DEFAULT 0 NOT NULL,
CONSTRAINT notification_pk PRIMARY KEY (id),
CONSTRAINT notification_unique UNIQUE (user_id,prio),
CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);