added adding and editing of availabilities

This commit is contained in:
z1glr
2025-01-18 13:25:12 +00:00
parent e37310b774
commit e17c9db90c
24 changed files with 737 additions and 163 deletions

3
backend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
config.yaml config.yaml
logs logs
__debug_bin* __debug_bin*
database.db

View File

@@ -6,6 +6,7 @@ require github.com/go-sql-driver/mysql v1.8.1
require ( require (
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -17,6 +18,8 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect
@@ -24,6 +27,9 @@ require (
golang.org/x/net v0.21.0 // indirect golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
) )
require ( require (
@@ -35,4 +41,5 @@ require (
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.5
) )

View File

@@ -3,6 +3,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -38,7 +40,11 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -66,3 +72,11 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=

View File

@@ -50,10 +50,10 @@ func (config ConfigStruct) SignJWT(val any) (string, error) {
return t.SignedString([]byte(config.ClientSession.JwtSignature)) return t.SignedString([]byte(config.ClientSession.JwtSignature))
} }
func loadConfig() ConfigStruct { func LoadConfig() ConfigStruct {
Config := ConfigYaml{} Config := ConfigYaml{}
yamlFile, err := os.ReadFile("config.yaml") yamlFile, err := os.ReadFile(CONFIG_PATH)
if err != nil { if err != nil {
panic(fmt.Sprintf("Error opening config-file: %q", err)) panic(fmt.Sprintf("Error opening config-file: %q", err))
} }
@@ -91,5 +91,5 @@ func loadConfig() ConfigStruct {
} }
func init() { func init() {
Config = loadConfig() Config = LoadConfig()
} }

View File

@@ -13,13 +13,8 @@ var CONFIG_PATH = "config.yaml"
type ConfigYaml struct { type ConfigYaml struct {
LogLevel string `yaml:"log_level"` LogLevel string `yaml:"log_level"`
Database struct { Database string `yaml:"database"`
Host string `yaml:"host"` Server struct {
User string `yaml:"user"`
Password string `yaml:"password"`
Database string `yaml:"database"`
} `yaml:"database"`
Server struct {
Port int `yaml:"port"` Port int `yaml:"port"`
} `yaml:"server"` } `yaml:"server"`
ClientSession struct { ClientSession struct {

View File

@@ -1,59 +1,58 @@
package availabilities package availabilities
import ( import (
"fmt"
"time"
cache "github.com/jfarleyx/go-simple-cache"
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
) )
type availabilitiesDB struct { type AvailabilityDB struct {
Id int `db:"id"` Id int `db:"id" json:"id" validate:"required"`
Text string `db:"text"` Availability
Disabled bool `db:"disabled"`
} }
type Availability struct { type Availability struct {
Text string Text string `db:"text" json:"text" validate:"required"`
Disabled bool Enabled bool `db:"enabled" json:"enabled" validate:"required"`
Color string `db:"color" json:"color" validate:"required"`
} }
var c *cache.Cache func Add(a Availability) error {
_, err := db.DB.NamedExec("INSERT INTO AVAILABILITIES (text, color, enabled) VALUES (:text, :color, :enabled)", a)
func Keys() (map[int]Availability, error) { return err
if availabilities, hit := c.Get("availabilities"); !hit { }
refresh()
return nil, fmt.Errorf("availabilities not stored cached") func Update(a AvailabilityDB) error {
_, err := db.DB.NamedExec("UPDATE AVAILABILITIES SET text = :text, color = :color, enabled = :enabled WHERE id = :id", a)
return err
}
func Slice() ([]AvailabilityDB, error) {
// get the availabilitiesRaw from the database
var availabilitiesRaw []AvailabilityDB
if err := db.DB.Select(&availabilitiesRaw, "SELECT * FROM AVAILABILITIES"); err != nil {
return nil, err
} else { } else {
return availabilities.(map[int]Availability), nil return availabilitiesRaw, nil
} }
} }
func refresh() { func Keys() (map[int]Availability, error) {
// get the availabilitiesRaw from the database if availabilitiesRaw, err := Slice(); err != nil {
var availabilitiesRaw []availabilitiesDB return nil, err
} else {
if err := db.DB.Select(&availabilitiesRaw, "SELECT * FROM AVAILABILITIES"); err == nil {
// convert the result in a map // convert the result in a map
availabilities := map[int]Availability{} availabilities := map[int]Availability{}
for _, a := range availabilitiesRaw { for _, a := range availabilitiesRaw {
availabilities[a.Id] = Availability{ availabilities[a.Id] = Availability{
Text: a.Text, Text: a.Text,
Disabled: a.Disabled, Enabled: a.Enabled,
Color: a.Color,
} }
} }
c.Set("availabilities", availabilities) return availabilities, nil
} }
} }
func init() {
c = cache.New(24 * time.Hour)
c.OnExpired(refresh)
refresh()
}

View File

@@ -1,11 +1,13 @@
package db package db
import ( import (
"time" "database/sql"
"fmt"
"os"
"github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config" _config "github.com/johannesbuehl/golunteer/backend/pkg/config"
_ "modernc.org/sqlite" // SQLite driver
) )
var config = _config.Config var config = _config.Config
@@ -14,20 +16,31 @@ var config = _config.Config
var DB *sqlx.DB var DB *sqlx.DB
func init() { func init() {
// setup the database-connection
sqlConfig := mysql.Config{
AllowNativePasswords: true,
Net: "tcp",
User: config.Database.User,
Passwd: config.Database.Password,
Addr: config.Database.Host,
DBName: config.Database.Database,
}
// connect to the database // connect to the database
DB = sqlx.MustOpen("mysql", sqlConfig.FormatDSN()) DB = sqlx.MustOpen("sqlite", config.Database)
DB.SetMaxIdleConns(10)
DB.SetMaxIdleConns(100)
DB.SetConnMaxLifetime(time.Minute)
// create the tables if they don't exist
if dbSetupInstructions, err := os.ReadFile("setup.sql"); err != nil {
panic("can't read database-setup")
} else {
DB.MustExec(string(dbSetupInstructions))
// take wether the admin-user is present as an indicator to a new instance
var admin struct {
Admin bool `db:"admin"`
}
if err := DB.QueryRowx("SELECT admin FROM USERS WHERE name = 'admin'").StructScan(&admin); err != nil {
// if the error isn't because there was no result, it's a real one
if err != sql.ErrNoRows {
panic(fmt.Errorf("can't query for the admin-user: %v", err))
} else {
// setup everything
setup()
fmt.Println("setup completed")
// reload the config
_config.LoadConfig()
}
}
}
} }

80
backend/pkg/db/setup.go Normal file
View File

@@ -0,0 +1,80 @@
package db
import (
"bytes"
"fmt"
"math/rand/v2"
"os"
"github.com/google/uuid"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
func createPassword(l int) string {
passwordChars := [...]string{`A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, `I`, `J`, `K`, `L`, `M`, `N`, `O`, `P`, `Q`, `R`, `S`, `T`, `U`, `V`, `W`, `X`, `Y`, `Z`, `Ä`, `Ö`, `Ü`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `h`, `i`, `j`, `k`, `l`, `m`, `n`, `o`, `p`, `q`, `r`, `s`, `t`, `u`, `v`, `w`, `x`, `y`, `z`, `ä`, `ö`, `ü`, `ß`, `0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `!`, `"`, `§`, `$`, `%`, `&`, `/`, `(`, `)`, `=`, `?`, `@`, `{`, `}`, `[`, `]`, `#`, `+`, `'`, `*`, `,`, `.`, `-`, `;`, `:`, `_`, `<`, `>`, `|`, `°`}
var password string
for ii := 0; ii < l; ii++ {
password += passwordChars[rand.IntN(len(passwordChars))]
}
return password
}
func setup() {
fmt.Println("Creating admin-password:")
// create an admin-password
const passwordLength = 20
password := createPassword(passwordLength)
// hash the admin-password
if passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err != nil {
panic(fmt.Errorf("can't generate password: %v", err))
} else {
fmt.Println("\thashed password")
// create an admin-user
user := struct {
Name string `db:"name"`
Password []byte `db:"password"`
Admin bool `db:"admin"`
TokenID string `db:"tokenID"`
}{
Name: "admin",
Password: passwordHash,
Admin: true,
TokenID: uuid.NewString(),
}
if _, err := DB.NamedExec("INSERT INTO USERS (name, password, tokenID, admin) VALUES (:name, :password, :tokenID, :admin)", &user); err != nil {
panic(fmt.Errorf("can't insert admin-user into the database: %v", err))
}
fmt.Println("\twrote hashed password to database")
}
fmt.Printf("created user \"admin\" with password %s\n", password)
// create a jwt-signature
config.ClientSession.JwtSignature = createPassword(100)
// write the modified config-file
WriteConfig()
}
func WriteConfig() {
buf := bytes.Buffer{}
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
// Can set default indent here on the encoder
if err := enc.Encode(&config.ConfigYaml); err != nil {
panic(err)
} else {
if err := os.WriteFile(_config.CONFIG_PATH, buf.Bytes(), 0644); err != nil {
panic(err)
}
}
}

View File

@@ -9,14 +9,14 @@ import (
) )
type tasksDB struct { type tasksDB struct {
ID int `db:"id"` ID int `db:"id"`
Text string `db:"text"` Text string `db:"text"`
Disabled bool `db:"disabled"` Enabled bool `db:"enabled"`
} }
type Task struct { type Task struct {
Text string `json:"text"` Text string `json:"text"`
Disabled bool `json:"disabled"` Enabled bool `json:"enabled"`
} }
var c *cache.Cache var c *cache.Cache
@@ -41,8 +41,8 @@ func refresh() {
for _, a := range tasksRaw { for _, a := range tasksRaw {
tasks[a.ID] = Task{ tasks[a.ID] = Task{
Text: a.Text, Text: a.Text,
Disabled: a.Disabled, Enabled: a.Enabled,
} }
} }

View File

@@ -37,7 +37,11 @@ func init() {
Out: os.Stdout, Out: os.Stdout,
TimeFormat: time.DateTime, TimeFormat: time.DateTime,
FormatLevel: func(i interface{}) string { FormatLevel: func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i)) if i == nil {
return "| LOG |"
} else {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
}
}, },
FormatFieldName: func(i interface{}) string { FormatFieldName: func(i interface{}) string {
return fmt.Sprintf("%s", i) return fmt.Sprintf("%s", i)

View File

@@ -0,0 +1,107 @@
package router
import (
"github.com/gofiber/fiber/v2"
"github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities"
)
func getAvailabilities(args HandlerArgs) responseMessage {
response := responseMessage{}
// get all the availabilites from the database
if avails, err := availabilities.Slice(); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't get availabilites: %v", err)
return response
} else {
response.Data = struct {
Availabilities []availabilities.AvailabilityDB `json:"availabilities"`
}{Availabilities: avails}
return response
}
}
func postAvailabilitie(args HandlerArgs) responseMessage {
response := responseMessage{}
// check admin
if !args.User.Admin {
response.Status = fiber.StatusUnauthorized
logger.Warn().Msg("user is no admin")
return response
// parse the body
} else {
var body availabilities.Availability
if err := args.C.BodyParser(&body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("can't parse body: %v", err)
return response
// validate the body
} else if err := validate.Struct(&response); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("invalid body: %v", err)
return response
} else if err := availabilities.Add(body); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't add availability: %v", err)
return response
} else {
return response
}
}
}
func patchAvailabilities(args HandlerArgs) responseMessage {
response := responseMessage{}
// check admin
if !args.User.Admin {
response.Status = fiber.StatusUnauthorized
logger.Warn().Msg("user is no admin")
return response
// parse the body
} else {
var body availabilities.AvailabilityDB
if err := args.C.BodyParser(&body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("can't parse body: %v", err)
return response
// validate the body
} else if err := validate.Struct(&response); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("invalid body: %v", err)
return response
} else if err := availabilities.Update(body); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't update availability: %v", err)
return response
} else {
return response
}
}
}

View File

@@ -81,14 +81,17 @@ func init() {
"events/user/pending": getEventsUserPending, "events/user/pending": getEventsUserPending,
"tasks": getTasks, "tasks": getTasks,
"users": getUsers, "users": getUsers,
"availabilities": getAvailabilities,
}, },
"POST": { "POST": {
"events": postEvent, "events": postEvent,
"users": postUser, "users": postUser,
"availabilities": postAvailabilitie,
}, },
"PATCH": { "PATCH": {
"users": patchUser, "users": patchUser,
"events": patchEvent, "events": patchEvent,
"availabilities": patchAvailabilities,
}, },
"PUT": { "PUT": {
"users/password": putPassword, "users/password": putPassword,

47
backend/setup.sql Normal file
View File

@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS TASKS (
id INTEGER PRIMARY KEY,
text varchar(64) NOT NULL,
enabled BOOL DEFAULT(true)
);
CREATE TABLE IF NOT EXISTS AVAILABILITIES (
id INTEGER PRIMARY KEY,
text varchar(32) NOT NULL,
color varchar(7) NOT NULL,
enabled BOOL DEFAULT(true)
);
CREATE TABLE IF NOT EXISTS USERS (
name varchar(64) PRIMARY KEY,
password binary(60) NOT NULL,
admin BOOL NOT NULL DEFAULT(false),
tokenID varchar(64) NOT NULL,
CHECK (length(password) = 60),
CHECK (length(tokenID) = 36)
);
CREATE TABLE IF NOT EXISTS EVENTS (
id INTEGER PRIMARY KEY,
date DATETIME NOT NULL,
description TEXT DEFAULT ""
);
CREATE TABLE IF NOT EXISTS USER_AVAILABILITIES (
userName varchar(64) NOT NULL,
eventID INTEGER NOT NULL,
availabilityID INTEGER NOT NULL,
PRIMARY KEY (userName, eventID),
FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (availabilityID) REFERENCES AVAILABILITIES(id) ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS USER_ASSIGNMENTS (
eventID INTEGER NOT NULL,
taskID INTEGER NOT NULL,
userName varchar(64),
PRIMARY KEY (eventID, taskID),
FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE,
FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (taskID) REFERENCES TASKS(id) ON UPDATE CASCADE
);

View File

@@ -18,33 +18,28 @@ export default function Main({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const user = zustand((state) => state.user);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
let loggedIn = false; let loggedIn = false;
if (zustand.getState().user === null) { const welcomeResult = await apiCall<{
const welcomeResult = await apiCall<{ userName: string;
userName: string; loggedIn: boolean;
loggedIn: boolean; }>("GET", "welcome");
}>("GET", "welcome");
if (welcomeResult.ok) { if (welcomeResult.ok) {
try { try {
const response = await welcomeResult.json(); const response = await welcomeResult.json();
if (response.userName !== undefined && response.userName !== "") { if (response.userName !== undefined && response.userName !== "") {
zustand.getState().reset({ user: response }); zustand.getState().reset({ user: response });
loggedIn = true; loggedIn = true;
} }
} catch {} } catch {}
} else {
zustand.getState().reset();
}
} else { } else {
loggedIn = true; zustand.getState().reset();
} }
if (pathname === "/login") { if (pathname === "/login") {
@@ -62,7 +57,7 @@ export default function Main({ children }: { children: React.ReactNode }) {
} }
} }
})(); })();
}, [pathname, router, user]); }, [pathname, router]);
switch (auth) { switch (auth) {
case AuthState.Loading: case AuthState.Loading:

View File

@@ -0,0 +1,33 @@
import { apiCall } from "@/lib";
import AvailabilityEditor, { Availability } from "./AvailabilityEditor";
import { Button } from "@heroui/react";
import { AddLarge } from "@carbon/icons-react";
export default function AddAvailability(props: {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
onSuccess?: () => void;
}) {
async function addAvailability(a: Availability) {
const result = await apiCall("POST", "availabilities", undefined, a);
if (result.ok) {
props.onSuccess?.();
props.onOpenChange?.(false);
}
}
return (
<AvailabilityEditor
header="Add Availability"
footer={
<Button type="submit" color="primary" startContent={<AddLarge />}>
Add
</Button>
}
isOpen={props.isOpen}
onOpenChange={props.onOpenChange}
onSubmit={addAvailability}
/>
);
}

View File

@@ -1,9 +1,164 @@
import ColorSelector from "@/components/Colorselector"; import { color2Tailwind, colors } from "@/components/Colorselector";
import { apiCall } from "@/lib";
import { Edit } from "@carbon/icons-react";
import {
Button,
Checkbox,
Chip,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
} from "@heroui/react";
import { useAsyncList } from "@react-stately/data";
import { useState } from "react";
import AddAvailability from "./AddAvailability";
import { Availability } from "./AvailabilityEditor";
import EditAvailability from "./EditAvailability";
export default function Availabilities() { export default function Availabilities() {
const [showAddAvailability, setShowAddAvailability] = useState(false);
const [editAvailability, setEditAvailability] = useState<Availability>();
const availabilities = useAsyncList<Availability>({
async load() {
const result = await apiCall("GET", "availabilities");
if (result.ok) {
const json = await result.json();
return {
items: json.availabilities,
};
} else {
return {
items: [],
};
}
},
async sort({ items, sortDescriptor }) {
return {
items: items.sort((a, b) => {
let cmp = 0;
switch (sortDescriptor.column) {
case "text":
cmp = a.text.localeCompare(b.text);
break;
case "enabled":
if (a.enabled && !b.enabled) {
cmp = -1;
} else if (!a.enabled && b.enabled) {
cmp = 1;
}
break;
case "color":
const aIndex = colors.findIndex((c) => c.value === a.color);
const bIndex = colors.findIndex((c) => c.value === a.color);
if (aIndex > bIndex) {
cmp = -1;
} else {
cmp = 1;
}
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
}),
};
},
});
const topContent = (
<>
<Button onPress={() => setShowAddAvailability(true)}>
Add Availability
</Button>
</>
);
return ( return (
<div> <div>
<ColorSelector /> <Table
aria-label="Table with the availabilites"
shadow="none"
isHeaderSticky
topContent={topContent}
sortDescriptor={availabilities.sortDescriptor}
onSortChange={availabilities.sort}
>
<TableHeader>
<TableColumn allowsSorting key="userName">
Text
</TableColumn>
<TableColumn allowsSorting key="color" align="center">
Color
</TableColumn>
<TableColumn allowsSorting key="admin" align="center">
Enabled
</TableColumn>
<TableColumn key="edit" align="center">
Edit
</TableColumn>
</TableHeader>
<TableBody items={availabilities.items}>
{(availability) => (
<TableRow key={availability.text}>
<TableCell
className={`text-${color2Tailwind(availability.color)}`}
>
{availability.text}
</TableCell>
<TableCell>
<Chip
classNames={{
base: `bg-${color2Tailwind(availability.color)}`,
}}
>
{availability.color}
</Chip>
</TableCell>
<TableCell>
<Checkbox isSelected={availability.enabled} />
</TableCell>
<TableCell>
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => setEditAvailability(availability)}
>
<Tooltip content="Edit availability">
<Edit />
</Tooltip>
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<AddAvailability
isOpen={showAddAvailability}
onOpenChange={setShowAddAvailability}
onSuccess={availabilities.reload}
/>
<EditAvailability
value={editAvailability}
isOpen={!!editAvailability}
onOpenChange={(isOpen) =>
!isOpen ? setEditAvailability(undefined) : null
}
onSuccess={availabilities.reload}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,93 @@
import ColorSelector from "@/components/Colorselector";
import {
Checkbox,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@heroui/react";
import React, { FormEvent, useState } from "react";
export interface Availability {
text: string;
color: string;
id: number | undefined;
enabled: boolean;
}
export default function AvailabilityEditor(props: {
header: React.ReactNode;
footer: React.ReactNode;
value?: Availability;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
onSubmit?: (e: Availability) => void;
}) {
const [text, setText] = useState(props.value?.text);
const [color, setColor] = useState(props.value?.color ?? "Red");
const [enabled, setEnabled] = useState(props.value?.enabled ?? true);
function submit(e: FormEvent<HTMLFormElement>) {
const formData = Object.fromEntries(new FormData(e.currentTarget)) as {
text: string;
color: string;
enabled: string;
};
props.onSubmit?.({
...formData,
id: props.value?.id,
enabled: formData.enabled == "true",
});
}
return (
<Modal
isOpen={props.isOpen}
onOpenChange={props.onOpenChange}
shadow={"none" as "sm"}
>
<Form
validationBehavior="native"
onSubmit={(e) => {
e.preventDefault();
submit(e);
}}
className="w-fit border-2"
>
<ModalContent>
<ModalHeader>
<h1>{props.header}</h1>
</ModalHeader>
<ModalBody>
<Input
value={text}
onValueChange={setText}
name="text"
label="Text"
isRequired
variant="bordered"
/>
<ColorSelector
value={color}
onValueChange={setColor}
name="color"
/>
<Checkbox
value="true"
isSelected={enabled}
onValueChange={setEnabled}
name="enabled"
>
Enabled
</Checkbox>
</ModalBody>
<ModalFooter>{props.footer} </ModalFooter>
</ModalContent>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,36 @@
import { apiCall } from "@/lib";
import AvailabilityEditor, { Availability } from "./AvailabilityEditor";
import { Button } from "@heroui/react";
import { Renew } from "@carbon/icons-react";
export default function EditAvailability(props: {
value: Availability | undefined;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
onSuccess?: () => void;
}) {
async function addAvailability(a: Availability) {
const result = await apiCall("PATCH", "availabilities", undefined, a);
if (result.ok) {
props.onSuccess?.();
props.onOpenChange?.(false);
}
}
return (
<AvailabilityEditor
key={props.value?.id}
header="Edit Availability"
footer={
<Button type="submit" color="primary" startContent={<Renew />}>
Update
</Button>
}
value={props.value}
isOpen={props.isOpen}
onOpenChange={props.onOpenChange}
onSubmit={addAvailability}
/>
);
}

View File

@@ -67,23 +67,23 @@ export default function EditUser(props: {
return ( return (
<Modal isOpen={props.isOpen} onOpenChange={props.onOpenChange}> <Modal isOpen={props.isOpen} onOpenChange={props.onOpenChange}>
{props.user !== undefined ? ( <Form
<ModalContent> validationBehavior="native"
<ModalHeader> onSubmit={(e) => {
<h1 className="text-2xl"> e.preventDefault();
Edit User{" "} updateUser(e);
<span className="font-numbers font-normal italic"> }}
{props.user.userName} >
</span> {props.user !== undefined ? (
</h1> <ModalContent>
</ModalHeader> <ModalHeader>
<Form <h1 className="text-2xl">
validationBehavior="native" Edit User{" "}
onSubmit={(e) => { <span className="font-numbers font-normal italic">
e.preventDefault(); {props.user.userName}
updateUser(e); </span>
}} </h1>
> </ModalHeader>
<ModalBody className="w-full"> <ModalBody className="w-full">
<Input <Input
label="Name" label="Name"
@@ -129,9 +129,9 @@ export default function EditUser(props: {
Update Update
</Button> </Button>
</ModalFooter> </ModalFooter>
</Form> </ModalContent>
</ModalContent> ) : null}
) : null} </Form>
</Modal> </Modal>
); );
} }

View File

@@ -118,7 +118,7 @@ export default function Users() {
size="sm" size="sm"
onPress={() => setEditUser(user)} onPress={() => setEditUser(user)}
> >
<Tooltip content="Edit event"> <Tooltip content="Edit user">
<Edit /> <Edit />
</Tooltip> </Tooltip>
</Button> </Button>

View File

@@ -40,26 +40,6 @@ import React, { Key, useState } from "react";
type EventWithAvailabilities = EventData & { availabilities: string[] }; type EventWithAvailabilities = EventData & { availabilities: string[] };
function availability2Tailwind(availability?: Availability) {
switch (availability) {
case "yes":
return "";
default:
return "italic";
}
}
function availability2Color(availability?: Availability) {
switch (availability) {
case "yes":
return "default";
case "maybe":
return "warning";
default:
return "danger";
}
}
export default function AdminPanel() { export default function AdminPanel() {
const [showAddEvent, setShowAddEvent] = useState(false); const [showAddEvent, setShowAddEvent] = useState(false);
const [editEvent, setEditEvent] = useState<EventData | undefined>(); const [editEvent, setEditEvent] = useState<EventData | undefined>();
@@ -75,7 +55,7 @@ export default function AdminPanel() {
{ key: "date", label: "Date" }, { key: "date", label: "Date" },
{ key: "description", label: "Description" }, { key: "description", label: "Description" },
...Object.values(tasks) ...Object.values(tasks)
.filter((task) => !task.disabled) .filter((task) => task.enabled)
.map((task) => ({ label: task.text, key: task.text })), .map((task) => ({ label: task.text, key: task.text })),
{ key: "actions", label: "Action" }, { key: "actions", label: "Action" },
], ],

View File

@@ -5,34 +5,49 @@ import {
VisuallyHidden, VisuallyHidden,
} from "@heroui/react"; } from "@heroui/react";
export default function ColorSelector() { export const colors = [
const colors = [ { value: "Red", tailwind: "red-600" },
{ value: "Red", tailwind: "red-600" }, { value: "Orange", tailwind: "orange-600" },
{ value: "Orange", tailwind: "orange-600" }, { value: "Amber", tailwind: "amber-600" },
{ value: "Amber", tailwind: "amber-600" }, { value: "Yellow", tailwind: "yellow-600" },
{ value: "Yellow", tailwind: "yellow-600" }, { value: "Lime", tailwind: "lime-600" },
{ value: "Lime", tailwind: "lime-600" }, { value: "Green", tailwind: "green-600" },
{ value: "Green", tailwind: "green-600" }, { value: "Emerald", tailwind: "emerald-600" },
{ value: "Emerald", tailwind: "emerald-600" }, { value: "Teal", tailwind: "teal-600" },
{ value: "Teal", tailwind: "teal-600" }, { value: "Cyan", tailwind: "cyan-600" },
{ value: "Cyan", tailwind: "cyan-600" }, { value: "Sky", tailwind: "sky-600" },
{ value: "Sky", tailwind: "sky-600" }, { value: "Blue", tailwind: "blue-600" },
{ value: "Blue", tailwind: "blue-600" }, { value: "Indigo", tailwind: "indigo-600" },
{ value: "Indigo", tailwind: "indigo-600" }, { value: "Violet", tailwind: "violet-600" },
{ value: "Violet", tailwind: "violet-600" }, { value: "Purple", tailwind: "purple-600" },
{ value: "Purple", tailwind: "purple-600" }, { value: "Fuchsia", tailwind: "fuchsia-600" },
{ value: "Fuchsia", tailwind: "fuchsia-600" }, { value: "Pink", tailwind: "pink-600" },
{ value: "Pink", tailwind: "pink-600" }, ];
];
export function color2Tailwind(v: string): string | undefined {
const find = colors.find((c) => c.value === v)?.tailwind;
return find;
}
export default function ColorSelector(props: {
name?: string;
value?: string;
onValueChange?: (value: string) => void;
}) {
return ( return (
<RadioGroup classNames={{ wrapper: "grid grid-cols-4" }}> <RadioGroup
value={props.value}
onValueChange={props.onValueChange}
classNames={{ wrapper: "grid grid-cols-4" }}
name={props.name}
>
{colors.map((color) => ( {colors.map((color) => (
<ColorRadio <ColorRadio
description={color.value} description={color.value}
value={color.value} value={color.value}
key={color.value} key={color.value}
radioColor={`bg-${color.tailwind}`} radiocolor={`bg-${color.tailwind}`}
> >
<div>{color.value}</div> <div>{color.value}</div>
</ColorRadio> </ColorRadio>
@@ -41,13 +56,13 @@ export default function ColorSelector() {
); );
} }
function ColorRadio(props: { radioColor: string } & RadioProps) { function ColorRadio(props: { radiocolor: string } & RadioProps) {
const { Component, children, getBaseProps, getInputProps } = useRadio(props); const { Component, children, getBaseProps, getInputProps } = useRadio(props);
return ( return (
<Component <Component
{...getBaseProps()} {...getBaseProps()}
className={`aspect-square cursor-pointer rounded-lg border-2 border-default tap-highlight-transparent hover:opacity-70 active:opacity-50 data-[selected=true]:border-primary ${props.radioColor} flex items-center justify-center p-1`} className={`aspect-square cursor-pointer rounded-lg border-2 border-default text-foreground transition tap-highlight-transparent hover:opacity-70 active:opacity-50 data-[selected=true]:border-2 data-[selected=true]:border-stone-300 ${props.radiocolor} flex select-none items-center justify-center p-1 text-sm`}
> >
<VisuallyHidden> <VisuallyHidden>
<input {...getInputProps()} /> <input {...getInputProps()} />

View File

@@ -183,7 +183,7 @@ export default function EditEvent(props: {
> >
{tasksMap !== undefined ? ( {tasksMap !== undefined ? (
Object.entries(tasksMap) Object.entries(tasksMap)
.filter(([, task]) => !task.disabled) .filter(([, task]) => task.enabled)
.map(([id, task]) => ( .map(([id, task]) => (
<div key={id}> <div key={id}>
<Checkbox value={id}>{task.text}</Checkbox> <Checkbox value={id}>{task.text}</Checkbox>

View File

@@ -95,14 +95,11 @@ export function vaidatePassword(password: string): string[] {
export interface Task { export interface Task {
text: string; text: string;
disabled: boolean; enabled: boolean;
} }
export async function getTasks(): Promise<Record<number, Task>> { export async function getTasks(): Promise<Record<number, Task>> {
const result = await apiCall<{ text: string; disabled: boolean }[]>( const result = await apiCall<Task[]>("GET", "tasks");
"GET",
"tasks",
);
if (result.ok) { if (result.ok) {
const tasks = await result.json(); const tasks = await result.json();