From e17c9db90c71edabdaf41d9810e9704b09bf94d6 Mon Sep 17 00:00:00 2001 From: z1glr Date: Sat, 18 Jan 2025 13:25:12 +0000 Subject: [PATCH] added adding and editing of availabilities --- backend/.gitignore | 3 +- backend/go.mod | 7 + backend/go.sum | 14 ++ backend/pkg/config/config.go | 6 +- backend/pkg/config/configYAML.go | 9 +- .../pkg/db/availabilities/availabilities.go | 63 ++++--- backend/pkg/db/db.go | 45 +++-- backend/pkg/db/setup.go | 80 +++++++++ backend/pkg/db/tasks/tasks.go | 14 +- backend/pkg/logger/logger.go | 6 +- backend/pkg/router/availabilities.go | 107 ++++++++++++ backend/pkg/router/router.go | 11 +- backend/setup.sql | 47 ++++++ client/src/app/Main.tsx | 33 ++-- client/src/app/admin/AddAvailability.tsx | 33 ++++ client/src/app/admin/Availabilities.tsx | 159 +++++++++++++++++- client/src/app/admin/AvailabilityEditor.tsx | 93 ++++++++++ client/src/app/admin/EditAvailability.tsx | 36 ++++ client/src/app/admin/EditUser.tsx | 40 ++--- client/src/app/admin/Users.tsx | 2 +- client/src/app/assignments/page.tsx | 22 +-- client/src/components/Colorselector.tsx | 61 ++++--- client/src/components/Event/EditEvent.tsx | 2 +- client/src/lib.ts | 7 +- 24 files changed, 737 insertions(+), 163 deletions(-) create mode 100644 backend/pkg/db/setup.go create mode 100644 backend/pkg/router/availabilities.go create mode 100644 backend/setup.sql create mode 100644 client/src/app/admin/AddAvailability.tsx create mode 100644 client/src/app/admin/AvailabilityEditor.tsx create mode 100644 client/src/app/admin/EditAvailability.tsx diff --git a/backend/.gitignore b/backend/.gitignore index efedbaa..d6fec14 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,4 @@ config.yaml logs -__debug_bin* \ No newline at end of file +__debug_bin* +database.db \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index f5c7f66..23b0613 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require github.com/go-sql-driver/mysql v1.8.1 require ( 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/go-playground/locales v0.14.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-isatty v0.0.20 // 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/valyala/bytebufferpool v1.0.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/sys v0.29.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 ( @@ -35,4 +41,5 @@ require ( golang.org/x/crypto v0.32.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.34.5 ) diff --git a/backend/go.sum b/backend/go.sum index a55d504..4a06f5b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 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/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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= diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 7b095bb..d7f127b 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -50,10 +50,10 @@ func (config ConfigStruct) SignJWT(val any) (string, error) { return t.SignedString([]byte(config.ClientSession.JwtSignature)) } -func loadConfig() ConfigStruct { +func LoadConfig() ConfigStruct { Config := ConfigYaml{} - yamlFile, err := os.ReadFile("config.yaml") + yamlFile, err := os.ReadFile(CONFIG_PATH) if err != nil { panic(fmt.Sprintf("Error opening config-file: %q", err)) } @@ -91,5 +91,5 @@ func loadConfig() ConfigStruct { } func init() { - Config = loadConfig() + Config = LoadConfig() } diff --git a/backend/pkg/config/configYAML.go b/backend/pkg/config/configYAML.go index 57befc8..a4966cc 100644 --- a/backend/pkg/config/configYAML.go +++ b/backend/pkg/config/configYAML.go @@ -13,13 +13,8 @@ var CONFIG_PATH = "config.yaml" type ConfigYaml struct { LogLevel string `yaml:"log_level"` - Database struct { - Host string `yaml:"host"` - User string `yaml:"user"` - Password string `yaml:"password"` - Database string `yaml:"database"` - } `yaml:"database"` - Server struct { + Database string `yaml:"database"` + Server struct { Port int `yaml:"port"` } `yaml:"server"` ClientSession struct { diff --git a/backend/pkg/db/availabilities/availabilities.go b/backend/pkg/db/availabilities/availabilities.go index 2534ddd..e374374 100644 --- a/backend/pkg/db/availabilities/availabilities.go +++ b/backend/pkg/db/availabilities/availabilities.go @@ -1,59 +1,58 @@ package availabilities import ( - "fmt" - "time" - - cache "github.com/jfarleyx/go-simple-cache" "github.com/johannesbuehl/golunteer/backend/pkg/db" ) -type availabilitiesDB struct { - Id int `db:"id"` - Text string `db:"text"` - Disabled bool `db:"disabled"` +type AvailabilityDB struct { + Id int `db:"id" json:"id" validate:"required"` + Availability } type Availability struct { - Text string - Disabled bool + Text string `db:"text" json:"text" validate:"required"` + 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) { - if availabilities, hit := c.Get("availabilities"); !hit { - refresh() + return err +} - 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 { - return availabilities.(map[int]Availability), nil + return availabilitiesRaw, nil } } -func refresh() { - // get the availabilitiesRaw from the database - var availabilitiesRaw []availabilitiesDB - - if err := db.DB.Select(&availabilitiesRaw, "SELECT * FROM AVAILABILITIES"); err == nil { +func Keys() (map[int]Availability, error) { + if availabilitiesRaw, err := Slice(); err != nil { + return nil, err + } else { // convert the result in a map availabilities := map[int]Availability{} for _, a := range availabilitiesRaw { availabilities[a.Id] = Availability{ - Text: a.Text, - Disabled: a.Disabled, + Text: a.Text, + 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() -} diff --git a/backend/pkg/db/db.go b/backend/pkg/db/db.go index 9b624bc..408c36f 100644 --- a/backend/pkg/db/db.go +++ b/backend/pkg/db/db.go @@ -1,11 +1,13 @@ package db import ( - "time" + "database/sql" + "fmt" + "os" - "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" _config "github.com/johannesbuehl/golunteer/backend/pkg/config" + _ "modernc.org/sqlite" // SQLite driver ) var config = _config.Config @@ -14,20 +16,31 @@ var config = _config.Config var DB *sqlx.DB 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 - DB = sqlx.MustOpen("mysql", sqlConfig.FormatDSN()) - DB.SetMaxIdleConns(10) - DB.SetMaxIdleConns(100) - DB.SetConnMaxLifetime(time.Minute) + DB = sqlx.MustOpen("sqlite", config.Database) + // 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() + } + } + } } diff --git a/backend/pkg/db/setup.go b/backend/pkg/db/setup.go new file mode 100644 index 0000000..7697483 --- /dev/null +++ b/backend/pkg/db/setup.go @@ -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) + } + } +} diff --git a/backend/pkg/db/tasks/tasks.go b/backend/pkg/db/tasks/tasks.go index 878c15a..ee25112 100644 --- a/backend/pkg/db/tasks/tasks.go +++ b/backend/pkg/db/tasks/tasks.go @@ -9,14 +9,14 @@ import ( ) type tasksDB struct { - ID int `db:"id"` - Text string `db:"text"` - Disabled bool `db:"disabled"` + ID int `db:"id"` + Text string `db:"text"` + Enabled bool `db:"enabled"` } type Task struct { - Text string `json:"text"` - Disabled bool `json:"disabled"` + Text string `json:"text"` + Enabled bool `json:"enabled"` } var c *cache.Cache @@ -41,8 +41,8 @@ func refresh() { for _, a := range tasksRaw { tasks[a.ID] = Task{ - Text: a.Text, - Disabled: a.Disabled, + Text: a.Text, + Enabled: a.Enabled, } } diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go index 106e584..a253ef5 100644 --- a/backend/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -37,7 +37,11 @@ func init() { Out: os.Stdout, TimeFormat: time.DateTime, 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 { return fmt.Sprintf("%s", i) diff --git a/backend/pkg/router/availabilities.go b/backend/pkg/router/availabilities.go new file mode 100644 index 0000000..b2665cb --- /dev/null +++ b/backend/pkg/router/availabilities.go @@ -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 + } + } +} diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index b73832f..a70297d 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -81,14 +81,17 @@ func init() { "events/user/pending": getEventsUserPending, "tasks": getTasks, "users": getUsers, + "availabilities": getAvailabilities, }, "POST": { - "events": postEvent, - "users": postUser, + "events": postEvent, + "users": postUser, + "availabilities": postAvailabilitie, }, "PATCH": { - "users": patchUser, - "events": patchEvent, + "users": patchUser, + "events": patchEvent, + "availabilities": patchAvailabilities, }, "PUT": { "users/password": putPassword, diff --git a/backend/setup.sql b/backend/setup.sql new file mode 100644 index 0000000..6451d16 --- /dev/null +++ b/backend/setup.sql @@ -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 +); diff --git a/client/src/app/Main.tsx b/client/src/app/Main.tsx index 1bc8459..7d39e60 100644 --- a/client/src/app/Main.tsx +++ b/client/src/app/Main.tsx @@ -18,33 +18,28 @@ export default function Main({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); - const user = zustand((state) => state.user); useEffect(() => { void (async () => { let loggedIn = false; - if (zustand.getState().user === null) { - const welcomeResult = await apiCall<{ - userName: string; - loggedIn: boolean; - }>("GET", "welcome"); + const welcomeResult = await apiCall<{ + userName: string; + loggedIn: boolean; + }>("GET", "welcome"); - if (welcomeResult.ok) { - try { - const response = await welcomeResult.json(); + if (welcomeResult.ok) { + try { + const response = await welcomeResult.json(); - if (response.userName !== undefined && response.userName !== "") { - zustand.getState().reset({ user: response }); + if (response.userName !== undefined && response.userName !== "") { + zustand.getState().reset({ user: response }); - loggedIn = true; - } - } catch {} - } else { - zustand.getState().reset(); - } + loggedIn = true; + } + } catch {} } else { - loggedIn = true; + zustand.getState().reset(); } if (pathname === "/login") { @@ -62,7 +57,7 @@ export default function Main({ children }: { children: React.ReactNode }) { } } })(); - }, [pathname, router, user]); + }, [pathname, router]); switch (auth) { case AuthState.Loading: diff --git a/client/src/app/admin/AddAvailability.tsx b/client/src/app/admin/AddAvailability.tsx new file mode 100644 index 0000000..2742372 --- /dev/null +++ b/client/src/app/admin/AddAvailability.tsx @@ -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 ( + }> + Add + + } + isOpen={props.isOpen} + onOpenChange={props.onOpenChange} + onSubmit={addAvailability} + /> + ); +} diff --git a/client/src/app/admin/Availabilities.tsx b/client/src/app/admin/Availabilities.tsx index 08267a8..7564762 100644 --- a/client/src/app/admin/Availabilities.tsx +++ b/client/src/app/admin/Availabilities.tsx @@ -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() { + const [showAddAvailability, setShowAddAvailability] = useState(false); + const [editAvailability, setEditAvailability] = useState(); + + const availabilities = useAsyncList({ + 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 = ( + <> + + + ); + return (
- + + + + Text + + + Color + + + Enabled + + + Edit + + + + {(availability) => ( + + + {availability.text} + + + + {availability.color} + + + + + + + + + + )} + +
+ + + + + !isOpen ? setEditAvailability(undefined) : null + } + onSuccess={availabilities.reload} + />
); } diff --git a/client/src/app/admin/AvailabilityEditor.tsx b/client/src/app/admin/AvailabilityEditor.tsx new file mode 100644 index 0000000..683fff1 --- /dev/null +++ b/client/src/app/admin/AvailabilityEditor.tsx @@ -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) { + 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 ( + +
{ + e.preventDefault(); + submit(e); + }} + className="w-fit border-2" + > + + +

{props.header}

+
+ + + + + Enabled + + + {props.footer} +
+
+
+ ); +} diff --git a/client/src/app/admin/EditAvailability.tsx b/client/src/app/admin/EditAvailability.tsx new file mode 100644 index 0000000..78f84fc --- /dev/null +++ b/client/src/app/admin/EditAvailability.tsx @@ -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 ( + }> + Update + + } + value={props.value} + isOpen={props.isOpen} + onOpenChange={props.onOpenChange} + onSubmit={addAvailability} + /> + ); +} diff --git a/client/src/app/admin/EditUser.tsx b/client/src/app/admin/EditUser.tsx index 5836cdc..fc2c126 100644 --- a/client/src/app/admin/EditUser.tsx +++ b/client/src/app/admin/EditUser.tsx @@ -67,23 +67,23 @@ export default function EditUser(props: { return ( - {props.user !== undefined ? ( - - -

- Edit User{" "} - - {props.user.userName} - -

-
-
{ - e.preventDefault(); - updateUser(e); - }} - > + { + e.preventDefault(); + updateUser(e); + }} + > + {props.user !== undefined ? ( + + +

+ Edit User{" "} + + {props.user.userName} + +

+
- -
- ) : null} +
+ ) : null} +
); } diff --git a/client/src/app/admin/Users.tsx b/client/src/app/admin/Users.tsx index 0a50259..fc1d334 100644 --- a/client/src/app/admin/Users.tsx +++ b/client/src/app/admin/Users.tsx @@ -118,7 +118,7 @@ export default function Users() { size="sm" onPress={() => setEditUser(user)} > - + diff --git a/client/src/app/assignments/page.tsx b/client/src/app/assignments/page.tsx index 35e70c4..4289d56 100644 --- a/client/src/app/assignments/page.tsx +++ b/client/src/app/assignments/page.tsx @@ -40,26 +40,6 @@ import React, { Key, useState } from "react"; 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() { const [showAddEvent, setShowAddEvent] = useState(false); const [editEvent, setEditEvent] = useState(); @@ -75,7 +55,7 @@ export default function AdminPanel() { { key: "date", label: "Date" }, { key: "description", label: "Description" }, ...Object.values(tasks) - .filter((task) => !task.disabled) + .filter((task) => task.enabled) .map((task) => ({ label: task.text, key: task.text })), { key: "actions", label: "Action" }, ], diff --git a/client/src/components/Colorselector.tsx b/client/src/components/Colorselector.tsx index 8a00d41..97fe783 100644 --- a/client/src/components/Colorselector.tsx +++ b/client/src/components/Colorselector.tsx @@ -5,34 +5,49 @@ import { VisuallyHidden, } from "@heroui/react"; -export default function ColorSelector() { - const colors = [ - { value: "Red", tailwind: "red-600" }, - { value: "Orange", tailwind: "orange-600" }, - { value: "Amber", tailwind: "amber-600" }, - { value: "Yellow", tailwind: "yellow-600" }, - { value: "Lime", tailwind: "lime-600" }, - { value: "Green", tailwind: "green-600" }, - { value: "Emerald", tailwind: "emerald-600" }, - { value: "Teal", tailwind: "teal-600" }, - { value: "Cyan", tailwind: "cyan-600" }, - { value: "Sky", tailwind: "sky-600" }, - { value: "Blue", tailwind: "blue-600" }, - { value: "Indigo", tailwind: "indigo-600" }, - { value: "Violet", tailwind: "violet-600" }, - { value: "Purple", tailwind: "purple-600" }, - { value: "Fuchsia", tailwind: "fuchsia-600" }, - { value: "Pink", tailwind: "pink-600" }, - ]; +export const colors = [ + { value: "Red", tailwind: "red-600" }, + { value: "Orange", tailwind: "orange-600" }, + { value: "Amber", tailwind: "amber-600" }, + { value: "Yellow", tailwind: "yellow-600" }, + { value: "Lime", tailwind: "lime-600" }, + { value: "Green", tailwind: "green-600" }, + { value: "Emerald", tailwind: "emerald-600" }, + { value: "Teal", tailwind: "teal-600" }, + { value: "Cyan", tailwind: "cyan-600" }, + { value: "Sky", tailwind: "sky-600" }, + { value: "Blue", tailwind: "blue-600" }, + { value: "Indigo", tailwind: "indigo-600" }, + { value: "Violet", tailwind: "violet-600" }, + { value: "Purple", tailwind: "purple-600" }, + { value: "Fuchsia", tailwind: "fuchsia-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 ( - + {colors.map((color) => (
{color.value}
@@ -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); return ( diff --git a/client/src/components/Event/EditEvent.tsx b/client/src/components/Event/EditEvent.tsx index 88d3ac5..7011642 100644 --- a/client/src/components/Event/EditEvent.tsx +++ b/client/src/components/Event/EditEvent.tsx @@ -183,7 +183,7 @@ export default function EditEvent(props: { > {tasksMap !== undefined ? ( Object.entries(tasksMap) - .filter(([, task]) => !task.disabled) + .filter(([, task]) => task.enabled) .map(([id, task]) => (
{task.text} diff --git a/client/src/lib.ts b/client/src/lib.ts index 085b4d9..296295c 100644 --- a/client/src/lib.ts +++ b/client/src/lib.ts @@ -95,14 +95,11 @@ export function vaidatePassword(password: string): string[] { export interface Task { text: string; - disabled: boolean; + enabled: boolean; } export async function getTasks(): Promise> { - const result = await apiCall<{ text: string; disabled: boolean }[]>( - "GET", - "tasks", - ); + const result = await apiCall("GET", "tasks"); if (result.ok) { const tasks = await result.json();