commit 93f3f7ee6b4259c3b42b6cf698ecf5b15da97f17
parent 91fff6f662c37479795ffa75e3538af64c82cd9f
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date: Wed, 20 Sep 2023 01:32:39 +0200
feat(book): draft curlable booking process
Diffstat:
5 files changed, 216 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
@@ -52,26 +52,36 @@ The application is configurable through environment variables or the
#HIVEDAV_CALENDAR=0
# Required
+HIVEDAV_HOST=hivedav.example.com
HIVEDAV_CALDAV_URI=
HIVEDAV_CALDAV_USER=
HIVEDAV_CALDAV_PASSWORD=
HIVEDAV_REFRESH_INTERVAL=30
+HIVEDAV_SMTP_SERVER=
+HIVEDAV_SMTP_USER=
+HIVEDAV_SMTP_PASSWORD=
```
There is an example config file provided in `./app.env.example`.
-The `HIVEDAV_CALENDAR` is a number that points to the index of the calendar
-collection in the ["calendar home
+* The `HIVEDAV_LISTEN_*` variables specify the http server `ADDRESS` and `PORT`
+ (bind address and port)
+* The `HIVEDAV_CALENDAR` is a number that points to the index of the calendar
+ collection in the ["calendar home
set"](https://www.rfc-editor.org/rfc/rfc4791.html#section-6.2.1) which should
-be used to read availability data.
-
-TODO: specify calendar using the "Name" propery
-
-`HIVEDAV_REFRESH_INTERVAL` is the time in minutes between the automatic refresh
-of the database with the latest availability data from the CalDAV server. A
-[cron job](https://pkg.go.dev/github.com/robfig/cron) automatically downloads
-the latest events from the CalDAV server after that time. Defaults to 30
-minutes.
+be used to read availability data
+* `HIVEDAV_HOST` is the fqdn of the HiveDAV service. It is used to print the
+ help commands in the curlable interface.
+* `HIVEDAV_CALDAV_*` are settings to connect to the CalDAV server for fetching
+ the avialability data
+* TODO: specify calendar using the "Name" propery
+* `HIVEDAV_REFRESH_INTERVAL` is the time in minutes between the automatic
+ refresh of the database with the latest availability data from the CalDAV
+server. A [cron job](https://pkg.go.dev/github.com/robfig/cron) automatically
+downloads the latest events from the CalDAV server after that time. Defaults to
+30 minutes.
+* The `HIVEDAV_SMTP_*` settings are required to send out the ical invites for
+ new appointments.
## Storage
The application stores the event start/end times in an sqlite database in the
@@ -162,6 +172,7 @@ stored in a local database for further processing.
```
TODO:
+- Time slots are 1h each (TODO: make it configurable)
- Sqlite date comparison always inclusive?
- Picture for the query
diff --git a/app.env.example b/app.env.example
@@ -1,7 +1,11 @@
#HIVEDAV_LISTEN_ADDRESS=127.0.0.1
#HIVEDAV_LISTEN_PORT=3737
+#HIVEDAV_HOST=hivedav.example.com
#HIVEDAV_CALDAV_URI=
#HIVEDAV_CALENDAR=0
#HIVEDAV_CALDAV_USER=
#HIVEDAV_CALDAV_PASSWORD=
#HIVEDAV_REFRESH_INTERVAL=30
+#HIVEDAV_SMTP_SERVER=
+#HIVEDAV_SMTP_USER=
+#HIVEDAV_SMTP_PASSWORD=
diff --git a/config/config.go b/config/config.go
@@ -6,6 +6,7 @@ import (
)
type Config struct {
+ HiveDavHost string `mapstructure:"HIVEDAV_HOST"`
ListenAddress string `mapstructure:"HIVEDAV_LISTEN_ADDRESS"`
ListenPort int `mapstructure:"HIVEDAV_LISTEN_PORT"`
CaldavUri string `mapstructure:"HIVEDAV_CALDAV_URI"`
@@ -13,6 +14,9 @@ type Config struct {
CaldavUser string `mapstructure:"HIVEDAV_CALDAV_USER"`
CaldavPassword string `mapstructure:"HIVEDAV_CALDAV_PASSWORD"`
RefreshInterval int `mapstructure:"HIVEDAV_REFRESH_INTERVAL"`
+ SmtpServer string `mapstructure:"HIVEDAV_SMTP_SERVER"`
+ SmtpUser string `mapstructure:"HIVEDAV_SMTP_USER"`
+ SmtpPassword string `mapstructure:"HIVEDAV_SMTP_PASSWORD"`
}
func (c *Config) LoadConfig(path string) (*Config, error) {
@@ -23,6 +27,7 @@ func (c *Config) LoadConfig(path string) (*Config, error) {
viper.SetConfigType("env")
// define some defaults
+ viper.SetDefault("HIVEDAV_HOST", "hivedav.example.com")
viper.SetDefault("HIVEDAV_LISTEN_PORT", 3737)
viper.SetDefault("HIVEDAV_LISTEN_ADDRESS", "[::]")
viper.SetDefault("HIVEDAV_CALENDAR", 0)
@@ -51,6 +56,9 @@ func (c *Config) LoadConfig(path string) (*Config, error) {
if viper.IsSet("LISTEN_PORT") {
c.ListenPort = viper.GetInt("LISTEN_PORT")
}
+ if viper.IsSet("HOST") {
+ c.CaldavUri = viper.GetString("HOST")
+ }
if viper.IsSet("CALDAV_URI") {
c.CaldavUri = viper.GetString("CALDAV_URI")
}
diff --git a/main.go b/main.go
@@ -27,8 +27,8 @@ func main() {
log.Fatal("Error creating server with config: ", err)
}
- log.Printf("Starting hivedav %s 🍯\n", hivedavVersion)
- log.Printf("Cron availability update is %d minutes ⏰\n", conf.RefreshInterval)
+ log.Printf("Starting hivedav %s, %s 🍯\n", hivedavVersion, conf.HiveDavHost)
+ log.Printf("Cron availability refresh interval is %d minutes ⏰\n", conf.RefreshInterval)
log.Println("Reading availability from:", conf.CaldavUri)
log.Println("Reading as user:", conf.CaldavUser)
@@ -40,6 +40,9 @@ func main() {
router := httprouter.New()
router.GET("/", server.Index)
router.GET("/week/:week", server.Week)
+ router.GET("/list", server.ListCubicles)
+ router.GET("/list/:week", server.ListCubiclesInWeek)
+ router.POST("/book/:dtstart", server.BookCubicle)
router.ServeFiles("/css/*filepath", http.Dir("./css"))
router.GET("/healthz", server.Healthz)
log.Printf("Listening on %s:%d\n", conf.ListenAddress, conf.ListenPort)
diff --git a/server.go b/server.go
@@ -18,6 +18,8 @@ import (
"strconv"
"strings"
"time"
+ "net/smtp"
+ "bytes"
)
type Server struct {
@@ -177,7 +179,7 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa
tableHead := []string{
"Time ",
fmt.Sprintf("Mon %s", monday.Format("02.01.")),
- fmt.Sprintf("Tue %s", monday.Add(time.Hour).Format("02.01.")),
+ fmt.Sprintf("Tue %s", monday.Add(time.Hour*24).Format("02.01.")),
fmt.Sprintf("Wed %s", monday.Add(time.Hour*24*2).Format("02.01.")),
fmt.Sprintf("Thu %s", monday.Add(time.Hour*24*3).Format("02.01.")),
fmt.Sprintf("Fri %s", monday.Add(time.Hour*24*4).Format("02.01.")),
@@ -233,6 +235,7 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa
if strings.Contains(userAgent, "curl") {
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year))
+ io.WriteString(w, fmt.Sprintf("Use 'curl %s/list/%d' to list cubicle booking commands\n", s.config.HiveDavHost, week))
pt := prettytable.New(tableData.TableHead)
for _, r := range tableData.Rows {
@@ -439,8 +442,179 @@ func (s *Server) Healthz(w http.ResponseWriter, req *http.Request, _ httprouter.
}
}
-func (s *Server) showCubicles() {
+func (s *Server) ListCubicles(w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
+ timeNow := time.Now()
+ _, currentWeek := timeNow.ISOWeek()
+ ps := httprouter.Params{
+ httprouter.Param{"week", strconv.Itoa(currentWeek)},
+ }
+ s.ListCubiclesInWeek(w, req, ps)
+}
+
+func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
+ // TODO: make display timezone configurable, display in local time
+ localLocation, err := time.LoadLocation("Local")
+ if err != nil {
+ return
+ }
+
+ userAgent := req.Header.Get("user-agent")
+
+ timeNow := time.Now()
+ weekday := timeNow.Weekday()
+ year, week := timeNow.ISOWeek()
+
+ // overwrite with requested week
+ week, err = strconv.Atoi(ps.ByName("week"))
+ if err != nil {
+ log.Printf("Cannot serve requested week '%d'\n", week)
+ w.WriteHeader(http.StatusBadRequest)
+ // TODO: Print human readable error to w
+ return
+ }
+
+ monday, week := dayOfISOWeek(1, week, year)
+ log.Printf("Current weekday is '%d'\n", weekday)
+ log.Printf("Serving week '%v' of year '%v'\n", week, year)
+ log.Printf("Monday is '%v'\n", monday)
+
+ sqlDateFmt := "2006-01-02 15:04"
+
+ var rows [][]string
+
+ // Define the table header
+ // TODO: Make heading date format configurable, start of week, etc..
+ tableHead := []string{
+ "Date/Time ",
+ // TODO: %!v(PANIC=String method: strings: negative Repeat count)
+ // with too many spaces
+ "Book Cubicle ",
+ }
+
+ tableData := TableData{
+ TableHead: tableHead,
+ Rows: rows,
+ Week: week,
+ Year: year,
+ Version: hivedavVersion,
+ }
+
+ // TODO: use timeIt to go through the loops below, remove the static arrays
+ timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, localLocation)
+
+ // 5 days * 9 1hour time slots = 45 possible booking commands
+ for d := 0; d < 5; d++ {
+ // 9 1hour time slots
+ for h := 8; h < 17; h++ {
+ // add/remove 1 minute to make the sql time query in available() below behave correctly
+ // convert to read/compare in UTC from db
+ startTime := timeIt.Add(time.Hour * time.Duration((d)*24+h))
+ endTime := timeIt.Add(time.Hour * time.Duration((d)*24+h+1))
+ start := startTime.Add(1 * time.Minute).UTC().Format(sqlDateFmt)
+ end := endTime.Add(-1 * time.Minute).UTC().Format(sqlDateFmt)
+ avi, err := s.available(start, end)
+ if err != nil {
+ log.Printf("Error getting availability on day '%d' hour '%d'\n", d, h)
+ return
+ }
+ if avi {
+ // Make two columns, for the date/time and the command
+ tableData.Rows = append(tableData.Rows, []string{
+ fmt.Sprintf("%s - %s", startTime.Format("Mon 02.01. 15:04"), endTime.Format("15:04")),
+ fmt.Sprintf("curl %s/book/%s -F 'mail=' -F 'msg='", s.config.HiveDavHost, startTime.Format("2006-01-02-15")),
+ })
+ }
+ }
+ }
+
+ if strings.Contains(userAgent, "curl") {
+ w.Header().Set("Content-Type", "text/plain")
+ io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year))
+ io.WriteString(w, fmt.Sprintf("Use 'curl %s/week/%d' to check availability\n", s.config.HiveDavHost, week))
+
+ pt := prettytable.New(tableData.TableHead)
+ for _, r := range tableData.Rows {
+ // convert to slice of interface
+ // https://go.dev/doc/faq#convert_slice_of_interface
+ s := make([]interface{}, len(r))
+ for i, v := range r {
+ s[i] = v
+ }
+ // unpack the string array into row arguments
+ pt.AddRow(s...)
+ }
+
+ io.WriteString(w, fmt.Sprint(pt))
+ io.WriteString(w, fmt.Sprintf("HiveDAV %s 🍯\n", hivedavVersion))
+ } else {
+ w.Header().Set("Content-Type", "text/html")
+
+ tmpl, err := template.ParseFiles("./templates/listcubicles.html")
+ if err != nil {
+ log.Printf("Error parsing html template: %v\n", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ // TODO: Print human readable error to w
+ return
+ }
+ err = tmpl.Execute(w, tableData)
+ if err != nil {
+ log.Printf("Error executing html template: %v\n", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ // TODO: Print human readable error to w
+ return
+ }
+ }
}
-func (s *Server) reserveCubicle() {
+func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
+ //TODO: create and attach ical file
+ dtstart := ps.ByName("dtstart")
+ //TODO: replace by config setting
+ dtstartTime, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local)
+ if err != nil {
+ log.Println("Error parsing booking dtstart")
+ return
+ }
+ log.Println("dtstart booking request: ", dtstartTime)
+
+ // total of maxMemory bytes of its file parts are stored in memory
+ // with the remainder stored on disk in temporary files
+ // https://pkg.go.dev/net/http#Request.ParseMultipartForm
+ err = req.ParseMultipartForm(256)
+ if err != nil {
+ fmt.Fprintf(w, "ParseMultipartForm() err: %v", err)
+ return
+ }
+
+ mail := req.FormValue("mail")
+ msg := req.FormValue("msg")
+ fmt.Fprintf(w, "Mail: %s\n", mail)
+ fmt.Fprintf(w, "Msg: %s\n", msg)
+ log.Printf("Form: %+v\n", req.Form)
+
+ if (mail == "" || msg == "") {
+ fmt.Fprintf(w, "mail and msg required, try again: 'curl %s/book/%s -F 'mail=' -F 'msg='", s.config.HiveDavHost, dtstart)
+ return
+ }
+
+ s.sendMail(mail, msg, dtstartTime)
+ // TODO: never returns
+ return
+}
+
+// book a time slot in the calendar
+func (s *Server) sendMail(bee string, msg string, dtstart time.Time) {
+
+ auth := smtp.PlainAuth("", s.config.SmtpUser, s.config.SmtpPassword, s.config.SmtpServer)
+ bees := []string{
+ s.config.CaldavUser,
+ bee,
+ }
+
+ var body bytes.Buffer
+ body.Write([]byte(fmt.Sprintln("Subject: HiveDAV test")))
+ err := smtp.SendMail(s.config.SmtpServer+":"+"587", auth, s.config.SmtpUser, bees, body.Bytes())
+ if err != nil {
+ log.Println("Error sending mail")
+ }
}