started working on the backend

This commit is contained in:
z1glr
2025-01-07 16:37:37 +00:00
parent 03b2b0e206
commit c3bc06fe82
34 changed files with 1437 additions and 54 deletions

View File

@@ -3,13 +3,13 @@
{ {
"name": "Go", "name": "Go",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm" "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [], // "forwardPorts": [8080]
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version", // "postCreateCommand": "go version",

15
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}"
}
]
}

View File

@@ -1,3 +1,31 @@
module github.com/johannesbuehl/golunteer/backend module github.com/johannesbuehl/golunteer/backend
go 1.23.4 go 1.23.4
require github.com/go-sql-driver/mysql v1.8.1
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jfarleyx/go-simple-cache v1.1.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
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/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.29.0 // indirect
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gofiber/fiber/v2 v2.52.6
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)

54
backend/go.sum Normal file
View File

@@ -0,0 +1,54 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jfarleyx/go-simple-cache v1.1.0 h1:R3sEDKnZwyrXbXiu1UswmuNPUK15uOGaIkaJEqs3OmU=
github.com/jfarleyx/go-simple-cache v1.1.0/go.mod h1:inxrRa6HjKThMm7/gARxbNMId0DMDf27u5N1c0Nz0Bw=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
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=

27
backend/logs/backend.log Normal file
View File

@@ -0,0 +1,27 @@
{"level":"debug","time":1736261973,"message":"HTTP GET request: \"/api/events\""}
{"level":"error","time":1736261977,"message":"error while reading data for event with id 1: Error 1241 (21000): Operand should contain 1 column(s)"}
{"level":"debug","time":1736262010,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736262031,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736263786,"message":"HTTP GET request: \"/api/events\""}
{"level":"error","time":1736263809,"message":"error while populating event with id = 1: Error 1054 (42S22): Unknown column 'USER_ASSIGNMENTS.userID' in 'ON'"}
{"level":"debug","time":1736263844,"message":"HTTP GET request: \"/api/events\""}
{"level":"error","time":1736263850,"message":"error while populating event with id = 1: Error 1054 (42S22): Unknown column 'USERS.id' in 'ON'"}
{"level":"debug","time":1736263865,"message":"HTTP GET request: \"/api/events\""}
{"level":"error","time":1736263865,"message":"error while populating event with id = 1: missing destination name eventID in *[]assignments.assignemntDB"}
{"level":"debug","time":1736263897,"message":"HTTP GET request: \"/api/events\""}
{"level":"error","time":1736263911,"message":"error while populating event with id = 1: missing destination name eventID in *[]assignments.assignemntDB"}
{"level":"debug","time":1736263915,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736263949,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264022,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264029,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264046,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264049,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264080,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264124,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264162,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264165,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264204,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264212,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264299,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264321,"message":"HTTP GET request: \"/api/events\""}
{"level":"debug","time":1736264349,"message":"HTTP GET request: \"/api/events\""}

9
backend/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/johannesbuehl/golunteer/backend/pkg/router"
)
func main() {
router.Listen()
}

View File

@@ -0,0 +1,95 @@
package config
import (
"bytes"
"fmt"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/johannesbuehl/golunteer/backend/pkg/lib"
"github.com/rs/zerolog"
"gopkg.in/yaml.v3"
)
type ReservationConfig struct {
Expiration time.Duration
}
type ConfigStruct struct {
ConfigYaml
LogLevel zerolog.Level
SessionExpire time.Duration
}
var Config ConfigStruct
type Payload struct {
jwt.RegisteredClaims
CustomClaims map[string]any
}
func (config ConfigStruct) SignJWT(val any) (string, error) {
valMap, err := lib.StrucToMap(val)
if err != nil {
return "", err
}
payload := Payload{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.SessionExpire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
CustomClaims: valMap,
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
return t.SignedString([]byte(config.ClientSession.JwtSignature))
}
func loadConfig() ConfigStruct {
Config := ConfigYaml{}
yamlFile, err := os.ReadFile("config.yaml")
if err != nil {
panic(fmt.Sprintf("Error opening config-file: %q", err))
}
reader := bytes.NewReader(yamlFile)
dec := yaml.NewDecoder(reader)
dec.KnownFields(true)
err = dec.Decode(&Config)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing config-file: %v", err)
os.Exit(1)
}
if logLevel, err := zerolog.ParseLevel(Config.LogLevel); err != nil {
panic(fmt.Errorf("can't parse log-level: %v", err))
} else {
var configStruct ConfigStruct
// parse the durations
if session_expire, err := time.ParseDuration(Config.ClientSession.Expire); err != nil {
log.Fatalf(`Error parsing "client_session.expire": %v`, err)
// parse the templates
} else {
configStruct = ConfigStruct{
ConfigYaml: Config,
LogLevel: logLevel,
SessionExpire: session_expire,
}
}
return configStruct
}
}
func init() {
Config = loadConfig()
}

View File

@@ -1,4 +1,4 @@
package main package config
import ( import (
"bytes" "bytes"
@@ -19,6 +19,9 @@ type ConfigYaml struct {
Password string `yaml:"password"` Password string `yaml:"password"`
Database string `yaml:"database"` Database string `yaml:"database"`
} `yaml:"database"` } `yaml:"database"`
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
ClientSession struct { ClientSession struct {
JwtSignature string `yaml:"jwt_signature"` JwtSignature string `yaml:"jwt_signature"`
Expire string `yaml:"expire"` Expire string `yaml:"expire"`
@@ -30,9 +33,9 @@ type CacheConfig struct {
Purge time.Duration Purge time.Duration
} }
var config ConfigYaml var YamlConfig ConfigYaml
func loadConfig() ConfigYaml { func _loadConfig() ConfigYaml {
config := ConfigYaml{} config := ConfigYaml{}
yamlFile, err := os.ReadFile(CONFIG_PATH) yamlFile, err := os.ReadFile(CONFIG_PATH)
@@ -53,12 +56,12 @@ func loadConfig() ConfigYaml {
return config return config
} }
func writeConfig() { func WriteConfig() {
buf := bytes.Buffer{} buf := bytes.Buffer{}
enc := yaml.NewEncoder(&buf) enc := yaml.NewEncoder(&buf)
enc.SetIndent(2) enc.SetIndent(2)
// Can set default indent here on the encoder // Can set default indent here on the encoder
if err := enc.Encode(&config); err != nil { if err := enc.Encode(&YamlConfig); err != nil {
panic(err) panic(err)
} else { } else {
if err := os.WriteFile(CONFIG_PATH, buf.Bytes(), 0644); err != nil { if err := os.WriteFile(CONFIG_PATH, buf.Bytes(), 0644); err != nil {
@@ -68,5 +71,5 @@ func writeConfig() {
} }
func init() { func init() {
config = loadConfig() YamlConfig = _loadConfig()
} }

View File

@@ -0,0 +1,30 @@
package assignments
import (
"github.com/johannesbuehl/golunteer/backend/pkg/db"
)
type assignments map[string]string
type assignemntDB struct {
TaskName string `db:"taskName"`
UserName string `db:"userName"`
}
func Event(eventID int) (assignments, error) {
// get the assignments from the database
var assignmentRows []assignemntDB
if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil {
return nil, err
} else {
// transform the rows into the returned map
eventAssignments := assignments{}
for _, assignment := range assignmentRows {
eventAssignments[assignment.TaskName] = assignment.UserName
}
return eventAssignments, nil
}
}

View File

@@ -0,0 +1,59 @@
package availabilites
import (
"fmt"
"time"
cache "github.com/jfarleyx/go-simple-cache"
"github.com/johannesbuehl/golunteer/backend/pkg/db"
)
type availabilitesDB struct {
Id int `db:"id"`
Text string `db:"text"`
Disabled bool `db:"disabled"`
}
type Availability struct {
Text string
Disabled bool
}
var c *cache.Cache
func Keys() (map[int]Availability, error) {
if availabilities, hit := c.Get("availabilites"); !hit {
refresh()
return nil, fmt.Errorf("availabilites not stored cached")
} else {
return availabilities.(map[int]Availability), nil
}
}
func refresh() {
// get the availabilitesRaw from the database
var availabilitesRaw []availabilitesDB
if err := db.DB.Select(&availabilitesRaw, "SELECT * FROM AVAILABILITIES"); err == nil {
// convert the result in a map
availabilites := map[int]Availability{}
for _, a := range availabilitesRaw {
availabilites[a.Id] = Availability{
Text: a.Text,
Disabled: a.Disabled,
}
}
c.Set("availabilites", availabilites)
}
}
func init() {
c = cache.New(24 * time.Hour)
c.OnExpired(refresh)
refresh()
}

View File

@@ -0,0 +1,36 @@
package availabilites
import (
"github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/users"
)
type eventAvailabilites struct {
userName string `db:"userName"`
AvailabilityID int `db:"availabilityID"`
}
func Event(eventID int) (map[string]string, error) {
// get the availabilites for the event
var availabilitesRows []eventAvailabilites
if err := db.DB.Select(&availabilitesRows, "SELECT (userID, availabilityID) FROM USER_AVAILABILITES WHERE eventID = ?", eventID); err != nil {
return nil, err
} else {
// transform the result into a map
eventAvailabilities := map[string]string{}
// get the availabilites
if availabilitesMap, err := Keys(); err != nil {
return nil, err
} else if usersMap, err := users.Get(); err != nil {
return nil, err
} else {
for _, a := range availabilitesRows {
eventAvailabilities[usersMap[a.userName].Name] = availabilitesMap[a.AvailabilityID].Text
}
return eventAvailabilities, nil
}
}
}

225
backend/pkg/db/db.go Normal file
View File

@@ -0,0 +1,225 @@
package db
import (
"database/sql"
"fmt"
"reflect"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config"
_logger "github.com/johannesbuehl/golunteer/backend/pkg/logger"
)
var logger = _logger.Logger
var config = _config.Config
// connection to database
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)
}
// query the database
func SelectOld[T any](table string, where string, args ...any) ([]T, error) {
// validate columns against struct T
tType := reflect.TypeOf(new(T)).Elem()
columns := make([]string, tType.NumField())
validColumns := make(map[string]any)
for ii := 0; ii < tType.NumField(); ii++ {
field := tType.Field(ii)
validColumns[strings.ToLower(field.Name)] = struct{}{}
columns[ii] = strings.ToLower(field.Name)
}
for _, col := range columns {
if _, ok := validColumns[strings.ToLower(col)]; !ok {
return nil, fmt.Errorf("invalid column: %s for struct type %T", col, new(T))
}
}
// create the query
completeQuery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(columns, ", "), table)
if where != "" && where != "*" {
completeQuery = fmt.Sprintf("%s WHERE %s", completeQuery, where)
}
var rows *sql.Rows
var err error
if len(args) > 0 {
DB.Ping()
rows, err = DB.Query(completeQuery, args...)
} else {
DB.Ping()
rows, err = DB.Query(completeQuery)
}
if err != nil {
logger.Error().Msgf("database access failed with error %v", err)
return nil, err
}
defer rows.Close()
results := []T{}
for rows.Next() {
var lineResult T
scanArgs := make([]any, len(columns))
v := reflect.ValueOf(&lineResult).Elem()
for ii, col := range columns {
field := v.FieldByName(col)
if field.IsValid() && field.CanSet() {
scanArgs[ii] = field.Addr().Interface()
} else {
logger.Warn().Msgf("Field %s not found in struct %T", col, lineResult)
scanArgs[ii] = new(any) // save dummy value
}
}
// scan the row into the struct
if err := rows.Scan(scanArgs...); err != nil {
logger.Warn().Msgf("Scan-error: %v", err)
return nil, err
}
results = append(results, lineResult)
}
if err := rows.Err(); err != nil {
logger.Error().Msgf("rows-error: %v", err)
return nil, err
} else {
return results, nil
}
}
// insert data intot the databse
func Insert(table string, vals any) error {
// extract columns from vals
v := reflect.ValueOf(vals)
t := v.Type()
columns := make([]string, t.NumField())
values := make([]any, t.NumField())
for ii := 0; ii < t.NumField(); ii++ {
fieldValue := v.Field(ii)
field := t.Field(ii)
columns[ii] = strings.ToLower(field.Name)
values[ii] = fieldValue.Interface()
}
placeholders := strings.Repeat(("?, "), len(columns))
placeholders = strings.TrimSuffix(placeholders, ", ")
completeQuery := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), placeholders)
_, err := DB.Exec(completeQuery, values...)
return err
}
// update data in the database
func Update(table string, set, where any) error {
setV := reflect.ValueOf(set)
setT := setV.Type()
setColumns := make([]string, setT.NumField())
setValues := make([]any, setT.NumField())
for ii := 0; ii < setT.NumField(); ii++ {
fieldValue := setV.Field(ii)
field := setT.Field(ii)
setColumns[ii] = strings.ToLower(field.Name) + " = ?"
setValues[ii] = fieldValue.Interface()
}
whereV := reflect.ValueOf(where)
whereT := whereV.Type()
whereColumns := make([]string, whereT.NumField())
whereValues := make([]any, whereT.NumField())
for ii := 0; ii < whereT.NumField(); ii++ {
fieldValue := whereV.Field(ii)
// skip empty (zero) values
if !fieldValue.IsZero() {
field := whereT.Field(ii)
whereColumns[ii] = strings.ToLower(field.Name) + " = ?"
whereValues[ii] = fmt.Sprint(fieldValue.Interface())
}
}
sets := strings.Join(setColumns, ", ")
wheres := strings.Join(whereColumns, " AND ")
placeholderValues := append(setValues, whereValues...)
completeQuery := fmt.Sprintf("UPDATE %s SET %s WHERE %s", table, sets, wheres)
_, err := DB.Exec(completeQuery, placeholderValues...)
return err
}
// remove data from the database
func Delete(table string, vals any) error {
// extract columns from vals
v := reflect.ValueOf(vals)
t := v.Type()
columns := make([]string, t.NumField())
values := make([]any, t.NumField())
for ii := 0; ii < t.NumField(); ii++ {
fieldValue := v.Field(ii)
// skip empty (zero) values
if !fieldValue.IsZero() {
field := t.Field(ii)
columns[ii] = strings.ToLower(field.Name) + " = ?"
values[ii] = fmt.Sprint(fieldValue.Interface())
}
}
completeQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", table, strings.Join(columns, ", "))
_, err := DB.Exec(completeQuery, values...)
return err
}

View File

@@ -0,0 +1,48 @@
package events
import (
"github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/assignments"
)
type Event struct {
eventDataDB
Tasks []string
Assignments map[string]string
}
type eventDataDB struct {
Id int `db:"id"`
Date string `db:"date"`
Description string `db:"description"`
}
// transform the database-entry to an Event
func (e *eventDataDB) Event() (Event, error) {
// get the availabilites associated with the event
if assignemnts, err := assignments.Event(e.Id); err != nil {
return Event{}, err
} else {
return Event{
eventDataDB: *e,
Assignments: assignemnts,
}, nil
}
}
// get all the event ids
func All() (map[int]eventDataDB, error) {
var dbRows []eventDataDB
if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS"); err != nil {
return nil, err
} else {
eventsMap := map[int]eventDataDB{}
for _, idRow := range dbRows {
eventsMap[idRow.Id] = idRow
}
return eventsMap, nil
}
}

View File

@@ -0,0 +1,59 @@
package tasks
import (
"fmt"
"time"
cache "github.com/jfarleyx/go-simple-cache"
"github.com/johannesbuehl/golunteer/backend/pkg/db"
)
type tasksDB struct {
Id int `db:"id"`
Text string `db:"text"`
Disabled bool `db:"disabled"`
}
type Task struct {
Text string
Disabled bool
}
var c *cache.Cache
func Get() (map[int]Task, error) {
if tasks, hit := c.Get("tasks"); !hit {
refresh()
return nil, fmt.Errorf("tasks not stored cached")
} else {
return tasks.(map[int]Task), nil
}
}
func refresh() {
// get the tasksRaw from the database
var tasksRaw []tasksDB
if err := db.DB.Select(&tasksRaw, "SELECT * FROM TASKS"); err == nil {
// convert the result in a map
tasks := map[int]Task{}
for _, a := range tasksRaw {
tasks[a.Id] = Task{
Text: a.Text,
Disabled: a.Disabled,
}
}
c.Set("tasks", tasks)
}
}
func init() {
c = cache.New(24 * time.Hour)
c.OnExpired(refresh)
refresh()
}

View File

@@ -0,0 +1,52 @@
package users
import (
"fmt"
"time"
cache "github.com/jfarleyx/go-simple-cache"
"github.com/johannesbuehl/golunteer/backend/pkg/db"
)
type User struct {
Name string `db:"text"`
Password []byte `db:"password"`
TokenID string `db:"tokenID"`
Admin bool `db:"disabled"`
}
var c *cache.Cache
func Get() (map[string]User, error) {
if users, hit := c.Get("users"); !hit {
refresh()
return nil, fmt.Errorf("users not stored cached")
} else {
return users.(map[string]User), nil
}
}
func refresh() {
// get the usersRaw from the database
var usersRaw []User
if err := db.DB.Select(&usersRaw, "SELECT * FROM USERS"); err == nil {
// convert the result in a map
users := map[string]User{}
for _, user := range usersRaw {
users[user.Name] = user
}
c.Set("users", users)
}
}
func init() {
c = cache.New(24 * time.Hour)
c.OnExpired(refresh)
refresh()
}

30
backend/pkg/lib/lib.go Normal file
View File

@@ -0,0 +1,30 @@
package lib
import (
"fmt"
"reflect"
)
func StrucToMap(data any) (map[string]any, error) {
result := make(map[string]any)
v := reflect.ValueOf(data)
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("expected a struct but got %T", data)
}
for ii := 0; ii < v.NumField(); ii++ {
field := v.Type().Field(ii)
value := v.Field(ii)
// skip unexported fields
if field.PkgPath != "" {
continue
}
result[field.Tag.Get("json")] = value.Interface()
}
return result, nil
}

View File

@@ -0,0 +1,70 @@
package logger
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/johannesbuehl/golunteer/backend/pkg/config"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
var Logger zerolog.Logger
type specificLevelWriter struct {
io.Writer
Level zerolog.Level
}
func (w specificLevelWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
if l >= w.Level {
return w.Write(p)
} else {
return len(p), nil
}
}
func init() {
// try to set the log-level
zerolog.SetGlobalLevel(config.Config.LogLevel)
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
// create the console output
outputConsole := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.DateTime,
FormatLevel: func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
},
FormatFieldName: func(i interface{}) string {
return fmt.Sprintf("%s", i)
},
NoColor: true,
}
// create the logfile output
outputLog := &lumberjack.Logger{
Filename: "logs/backend.log",
MaxAge: 7,
LocalTime: true,
}
// create a multi-output-writer
multi := zerolog.MultiLevelWriter(
specificLevelWriter{
Writer: outputConsole,
Level: config.Config.LogLevel,
},
specificLevelWriter{
Writer: outputLog,
Level: config.Config.LogLevel,
},
)
// create a logger-instance
Logger = zerolog.New(multi).With().Timestamp().Logger()
}

View File

@@ -0,0 +1,33 @@
package router
import (
"github.com/gofiber/fiber/v2"
"github.com/johannesbuehl/golunteer/backend/pkg/db/events"
)
func getEvents(c *fiber.Ctx) responseMessage {
response := responseMessage{}
// get all eventRows
if eventRows, err := events.All(); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("events retrieving failed: %v", err)
} else {
// get the data for all the allEvents
allEvents := []events.Event{}
for _, eventRow := range eventRows {
if e, err := eventRow.Event(); err != nil {
logger.Error().Msgf("error while populating event with id = %d: %v", eventRow.Id, err)
} else {
allEvents = append(allEvents, e)
}
// response.Data = struct{ Events []events.Event }{Events: allEvents}
response.Data = allEvents
}
}
return response
}

View File

@@ -0,0 +1,80 @@
package router
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/johannesbuehl/golunteer/backend/pkg/db"
)
type UserLogin struct {
UserID int `json:"userID"`
Name string `json:"name"`
LoggedIn bool `json:"loggedIn"`
}
// handle welcome-messages from clients
func handleWelcome(c *fiber.Ctx) error {
logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
response := responseMessage{}
response.Data = UserLogin{
LoggedIn: false,
}
if ok, err := checkUser(c); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Warn().Msgf("can't check user: %v", err)
} else if !ok {
response.Status = fiber.StatusNoContent
} else {
if uid, _, err := extractJWT(c); err != nil {
response.Status = fiber.StatusBadRequest
logger.Error().Msgf("can't extract JWT: %v", err)
} else {
if users, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", strconv.Itoa(uid)); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't get users from database: %v", err)
} else {
if len(users) != 1 {
response.Status = fiber.StatusForbidden
response.Message = "unknown user"
removeSessionCookie(c)
} else {
user := users[0]
response.Data = UserLogin{
UserID: user.UserID,
Name: user.Name,
LoggedIn: true,
}
}
logger.Debug().Msgf("welcomed user with uid = %v", uid)
}
}
}
return response.send(c)
}
func handleLogin(c *fiber.Ctx) error {
panic("not implemented yet")
}
// handles logout-requests
func handleLogout(c *fiber.Ctx) error {
logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
removeSessionCookie(c)
return responseMessage{
Data: UserLogin{
LoggedIn: false,
},
}.send(c)
}

View File

@@ -0,0 +1,221 @@
package router
import (
"fmt"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config"
"github.com/johannesbuehl/golunteer/backend/pkg/db"
_logger "github.com/johannesbuehl/golunteer/backend/pkg/logger"
)
var logger = _logger.Logger
var config = _config.Config
var app *fiber.App
// general message for REST-responses
type responseMessage struct {
Status int
Message string
Data any
}
// answer the client request with the response-message
func (result responseMessage) send(c *fiber.Ctx) error {
// if the status-code is in the error-region, return an error
if result.Status >= 400 {
// if available, include the message
if result.Message != "" {
return fiber.NewError(result.Status, result.Message)
} else {
return fiber.NewError(result.Status)
}
} else {
// if there is data, send it as JSON
if result.Data != nil {
c.JSON(result.Data)
// if there is a message, send it instead
} else if result.Message != "" {
c.SendString(result.Message)
}
return c.SendStatus(result.Status)
}
}
func init() {
// setup fiber
app = fiber.New(fiber.Config{
AppName: "johannes-pv",
DisableStartupMessage: true,
})
// map with the individual methods
handleMethods := map[string]func(path string, handlers ...func(*fiber.Ctx) error) fiber.Router{
"GET": app.Get,
"POST": app.Post,
"PATCH": app.Patch,
"DELETE": app.Delete,
}
// map with the individual registered endpoints
endpoints := map[string]map[string]func(*fiber.Ctx) responseMessage{
"GET": {"events": getEvents},
"POST": {},
"PATCH": {},
"DELETE": {},
}
// handle specific requests special
app.Get("/api/welcome", handleWelcome)
app.Post("/api/login", handleLogin)
app.Get("/api/logout", handleLogout)
// register the registered endpoints
for method, handlers := range endpoints {
for address, handler := range handlers {
handleMethods[method]("/api/"+address, func(c *fiber.Ctx) error {
logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
return handler(c).send(c)
})
}
}
}
func Listen() {
// start the server
err := app.Listen(fmt.Sprintf(":%d", config.Server.Port))
fmt.Println(err)
}
func setSessionCookie(c *fiber.Ctx, jwt *string) {
var value string
if jwt == nil {
value = c.Cookies("session")
} else {
value = *jwt
}
c.Cookie(&fiber.Cookie{
Name: "session",
Value: value,
HTTPOnly: true,
SameSite: "strict",
MaxAge: int(config.SessionExpire.Seconds()),
})
}
// removes the session-coockie from a request
func removeSessionCookie(c *fiber.Ctx) {
c.Cookie(&fiber.Cookie{
Name: "session",
Value: "",
HTTPOnly: true,
SameSite: "strict",
Expires: time.Unix(0, 0),
})
}
// payload of the JSON webtoken
type JWTPayload struct {
UserID int `json:"userID"`
TokenID string `json:"tokenID"`
}
// complete JSON webtoken
type JWT struct {
_config.Payload
CustomClaims JWTPayload
}
// extracts the json webtoken from the request
//
// @returns (userID, tokenID, error)
func extractJWT(c *fiber.Ctx) (int, string, error) {
// get the session-cookie
cookie := c.Cookies("session")
token, err := jwt.ParseWithClaims(cookie, &JWT{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected JWT signing method: %v", token.Header["alg"])
}
return []byte(config.ClientSession.JwtSignature), nil
})
if err != nil {
return -1, "", err
}
// extract the claims from the JWT
if claims, ok := token.Claims.(*JWT); ok && token.Valid {
return claims.CustomClaims.UserID, claims.CustomClaims.TokenID, nil
} else {
return -1, "", fmt.Errorf("invalid JWT")
}
}
// user-entry in the database
type UserDB struct {
UserID int `json:"userID"`
Name string `json:"name"`
Password []byte `json:"password"`
Admin bool `json:"admin"`
TokenID string `json:"tokenID"`
}
// checks wether the request is from a valid user
func checkUser(c *fiber.Ctx) (bool, error) {
uid, tid, err := extractJWT(c)
if err != nil {
return false, nil
}
// retrieve the user from the database
response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid)
if err != nil {
return false, err
}
// if exactly one user came back and the tID is valid, the user is authorized
if len(response) == 1 && response[0].TokenID == tid {
// reset the expiration of the cookie
setSessionCookie(c, nil)
return true, err
} else {
return false, err
}
}
// checks wether the request is from the admin
func checkAdmin(c *fiber.Ctx) (bool, error) {
uid, tokenID, err := extractJWT(c)
if err != nil {
return false, err
}
// retrieve the user from the database
response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid)
if err != nil {
return false, err
}
// if exactly one user came back and its name is "admin", the user is the admin
if len(response) != 1 {
return false, fmt.Errorf("user doesn't exist")
} else {
return response[0].Name == "admin" && response[0].TokenID == tokenID, err
}
}

View File

@@ -0,0 +1,13 @@
package router
import "golang.org/x/crypto/bcrypt"
// hashes a password
func hashPassword(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
}
// validates a password against the password-rules
func validatePassword(password string) bool {
return len(password) >= 12 && len(password) <= 64
}

View File

@@ -2,7 +2,17 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "export" // output: "export",
rewrites: async () => ({
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/api/:path*",
destination: "http://golunteer-frontend:8080/api/:path*",
},
],
}),
}; };
export default nextConfig; export default nextConfig;

View File

@@ -15,10 +15,12 @@
"@nextui-org/checkbox": "^2.3.8", "@nextui-org/checkbox": "^2.3.8",
"@nextui-org/date-picker": "^2.3.9", "@nextui-org/date-picker": "^2.3.9",
"@nextui-org/divider": "^2.2.5", "@nextui-org/divider": "^2.2.5",
"@nextui-org/form": "^2.1.8",
"@nextui-org/input": "^2.4.8", "@nextui-org/input": "^2.4.8",
"@nextui-org/modal": "^2.2.7", "@nextui-org/modal": "^2.2.7",
"@nextui-org/radio": "^2.3.8", "@nextui-org/radio": "^2.3.8",
"@nextui-org/select": "^2.4.9", "@nextui-org/select": "^2.4.9",
"@nextui-org/switch": "^2.2.8",
"@nextui-org/system": "^2.4.6", "@nextui-org/system": "^2.4.6",
"@nextui-org/table": "^2.2.8", "@nextui-org/table": "^2.2.8",
"@nextui-org/theme": "^2.4.5", "@nextui-org/theme": "^2.4.5",
@@ -1498,6 +1500,30 @@
"react-dom": ">=18 || >=19.0.0-rc.0" "react-dom": ">=18 || >=19.0.0-rc.0"
} }
}, },
"node_modules/@nextui-org/switch": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@nextui-org/switch/-/switch-2.2.8.tgz",
"integrity": "sha512-wk9qQSOfUEtmdWR1omKjmEYzgMjJhVizvfW6Z0rKOiMUuSud2d4xYnUmZhU22cv2WtoPV//kBjXkYD/E/t6rdg==",
"license": "MIT",
"dependencies": {
"@nextui-org/react-utils": "2.1.3",
"@nextui-org/shared-utils": "2.1.2",
"@nextui-org/use-safe-layout-effect": "2.1.1",
"@react-aria/focus": "3.19.0",
"@react-aria/interactions": "3.22.5",
"@react-aria/switch": "3.6.10",
"@react-aria/utils": "3.26.0",
"@react-aria/visually-hidden": "3.8.18",
"@react-stately/toggle": "3.8.0",
"@react-types/shared": "3.26.0"
},
"peerDependencies": {
"@nextui-org/system": ">=2.4.0",
"@nextui-org/theme": ">=2.4.3",
"react": ">=18 || >=19.0.0-rc.0",
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/system": { "node_modules/@nextui-org/system": {
"version": "2.4.6", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.6.tgz", "resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.6.tgz",
@@ -2179,6 +2205,22 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/@react-aria/switch": {
"version": "3.6.10",
"resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.6.10.tgz",
"integrity": "sha512-FtaI9WaEP1tAmra1sYlAkYXg9x75P5UtgY8pSbe9+1WRyWbuE1QZT+RNCTi3IU4fZ7iJQmXH6+VaMyzPlSUagw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/toggle": "^3.10.10",
"@react-stately/toggle": "^3.8.0",
"@react-types/shared": "^3.26.0",
"@react-types/switch": "^3.5.7",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/table": { "node_modules/@react-aria/table": {
"version": "3.16.0", "version": "3.16.0",
"resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.16.0.tgz", "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.16.0.tgz",
@@ -2759,6 +2801,18 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/@react-types/switch": {
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.7.tgz",
"integrity": "sha512-1IKiq510rPTHumEZuhxuazuXBa2Cuxz6wBIlwf3NCVmgWEvU+uk1ETG0sH2yymjwCqhtJDKXi+qi9HSgPEDwAg==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.26.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/table": { "node_modules/@react-types/table": {
"version": "3.10.3", "version": "3.10.3",
"resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.10.3.tgz", "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.10.3.tgz",

View File

@@ -16,10 +16,12 @@
"@nextui-org/checkbox": "^2.3.8", "@nextui-org/checkbox": "^2.3.8",
"@nextui-org/date-picker": "^2.3.9", "@nextui-org/date-picker": "^2.3.9",
"@nextui-org/divider": "^2.2.5", "@nextui-org/divider": "^2.2.5",
"@nextui-org/form": "^2.1.8",
"@nextui-org/input": "^2.4.8", "@nextui-org/input": "^2.4.8",
"@nextui-org/modal": "^2.2.7", "@nextui-org/modal": "^2.2.7",
"@nextui-org/radio": "^2.3.8", "@nextui-org/radio": "^2.3.8",
"@nextui-org/select": "^2.4.9", "@nextui-org/select": "^2.4.9",
"@nextui-org/switch": "^2.2.8",
"@nextui-org/system": "^2.4.6", "@nextui-org/system": "^2.4.6",
"@nextui-org/table": "^2.2.8", "@nextui-org/table": "^2.2.8",
"@nextui-org/theme": "^2.4.5", "@nextui-org/theme": "^2.4.5",

View File

@@ -7,10 +7,25 @@ import AddEvent from "../components/Event/AddEvent";
import zustand from "../Zustand"; import zustand from "../Zustand";
import { Button } from "@nextui-org/button"; import { Button } from "@nextui-org/button";
import AssignmentTable from "@/components/Event/AssignmentTable"; import AssignmentTable from "@/components/Event/AssignmentTable";
import { useAsyncList } from "@react-stately/data";
import { apiCall } from "@/lib";
export default function EventVolunteer() { export default function EventVolunteer() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false); const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
// fetch the events from the server
useAsyncList({
load: async () => {
const data = await apiCall("GET", "events");
console.debug(await data.json());
return {
items: [],
};
},
});
return ( return (
<div className="relative flex-1 p-4"> <div className="relative flex-1 p-4">
<h2 className="mb-4 text-center text-4xl">Overview</h2> <h2 className="mb-4 text-center text-4xl">Overview</h2>

View File

@@ -40,8 +40,4 @@
h6 { h6 {
@apply font-headline text-highlight; @apply font-headline text-highlight;
} }
input {
@apply border-2 border-accent-1 bg-transparent;
}
} }

View File

@@ -1,5 +1,54 @@
import EventVolunteer from "./Overview"; "use client";
import { Input } from "@nextui-org/input";
import { useState } from "react";
import { ViewFilled, ViewOffFilled } from "@carbon/icons-react";
import { Switch } from "@nextui-org/switch";
import { Button } from "@nextui-org/button";
import { Form } from "@nextui-org/form";
export default function Home() { export default function Home() {
return <EventVolunteer />; const [visibility, setVisibility] = useState(false);
// return <EventVolunteer />;
return (
<div>
<h2 className="mb-4 text-center text-4xl">Login</h2>
<Form
validationBehavior="native"
className="flex flex-col items-center gap-2"
onSubmit={(e) => e.preventDefault()}
>
<Input
isRequired
type="user"
label="Name"
name="username"
variant="bordered"
className="max-w-xs"
/>
<Input
isRequired
label="Password"
name="password"
autoComplete="current-password"
endContent={
<Switch
className="my-auto"
startContent={<ViewFilled />}
endContent={<ViewOffFilled />}
onValueChange={setVisibility}
isSelected={visibility}
/>
}
type={visibility ? "text" : "password"}
variant="bordered"
className="max-w-xs"
/>
<Button className="w-full max-w-xs" color="primary" type="submit">
Login
</Button>
</Form>
</div>
);
} }

56
client/src/lib.ts Normal file
View File

@@ -0,0 +1,56 @@
type QueryParams = Record<string, string | { toString(): string }>;
export type APICallResult<T extends object> = Response & { json: () => Promise<T> };
export async function apiCall<K extends object>(
method: "GET",
api: string,
params?: QueryParams
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "POST" | "PATCH",
api: string,
params?: QueryParams,
body?: object
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "DELETE",
api: string,
params?: QueryParams,
body?: object
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "GET" | "POST" | "PATCH" | "DELETE",
api: string,
params?: QueryParams,
body?: object
): Promise<APICallResult<K>> {
let url = window.origin + "/api/" + api;
if (params) {
const urlsearchparams = new URLSearchParams(
Object.fromEntries(
Object.entries(params).map(([key, value]): [string, string] => {
if (typeof value !== "string") {
return [key, value.toString()];
} else {
return [key, value];
}
})
)
);
url += "?" + urlsearchparams.toString();
}
const response = await fetch(url, {
headers: {
"Content-Type": "application/json; charset=UTF-8"
},
credentials: "include",
method,
body: body !== undefined ? JSON.stringify(body) : undefined
});
return response;
}

View File

@@ -24,16 +24,16 @@ export default {
foreground: { foreground: {
DEFAULT: FOREGROUND, DEFAULT: FOREGROUND,
"50": FOREGROUND, "50": FOREGROUND,
"100": "#fce8ff", "100": FOREGROUND,
"200": "#fad0fe", "200": FOREGROUND,
"300": "#f8abfc", "300": FOREGROUND,
"400": "#f579f9", "400": FOREGROUND,
"500": "#eb46ef", "500": FOREGROUND,
"600": "#d226d3", "600": "#fce8ff",
"700": "#af1cad", "700": "#fad0fe",
"800": "#8f198c", "800": "#f8abfc",
"900": "#751a70", "900": "#f579f9",
"950": "#4e044a", "950": "#eb46ef",
}, },
"accent-1": ACCENT1, "accent-1": ACCENT1,
"accent-2": ACCENT2, "accent-2": ACCENT2,

9
init.sql Normal file
View File

@@ -0,0 +1,9 @@
INSERT INTO TASKS (text) VALUES ("Ton"), ("Livestream"), ("Kamera"), ("Licht"), ("Livestream Ton");
INSERT INTO AVAILABILITIES (text) VALUES ("Ja"), ("Eventuell"), ("Nein");
INSERT INTO EVENTS(date, description) VALUES ("2025-01-05T11:00", "Neuer Prädikant");
INSERT INTO USER_AVAILABILITIES (userName, eventID, availabilityID) VALUES ("admin", 1, 1);
INSERT INTO USER_ASSIGNMENTS (eventID, taskID, userName) VALUES (1, 1, "admin");

1
setup/.gitignore vendored
View File

@@ -1 +1,2 @@
config.yaml config.yaml
passwords

1
setup/pkg/config/config.go Symbolic link
View File

@@ -0,0 +1 @@
../../../backend/pkg/config/configYAML.go

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
_config "github.com/johannesbuehl/golunteer/setup/pkg/config"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -29,6 +30,8 @@ func exit(e error) {
} }
func main() { func main() {
config := &_config.YamlConfig
fmt.Println("connecting to database") fmt.Println("connecting to database")
// connect to the database // connect to the database
@@ -114,5 +117,5 @@ func main() {
config.ClientSession.JwtSignature = createPassword(100) config.ClientSession.JwtSignature = createPassword(100)
// write the modified config-file // write the modified config-file
writeConfig() _config.WriteConfig()
} }

View File

@@ -11,10 +11,10 @@ CREATE TABLE AVAILABILITIES (
); );
CREATE TABLE USERS ( CREATE TABLE USERS (
id INTEGER PRIMARY KEY AUTO_INCREMENT, name varchar(64) PRIMARY KEY,
name varchar(64) NOT NULL,
password binary(60) NOT NULL, password binary(60) NOT NULL,
admin BOOL NOT NULL DEFAULT(false) admin BOOL NOT NULL DEFAULT(false),
tokenID varchar(64) DEFAULT NULL
); );
CREATE TABLE EVENTS ( CREATE TABLE EVENTS (
@@ -24,21 +24,21 @@ CREATE TABLE EVENTS (
); );
CREATE TABLE USER_AVAILABILITIES ( CREATE TABLE USER_AVAILABILITIES (
userID INTEGER NOT NULL, userName varchar(64) NOT NULL,
eventID INTEGER NOT NULL, eventID INTEGER NOT NULL,
availabilityID INTEGER NOT NULL, availabilityID INTEGER NOT NULL,
PRIMARY KEY (userID, eventID), PRIMARY KEY (userName, eventID),
FOREIGN KEY (userID) REFERENCES USERS(id) ON DELETE CASCADE, FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE, FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (avaibID) REFERENCES AVAILABILITIES(id) ON DELETE RESTRICT, FOREIGN KEY (availabilityID) REFERENCES AVAILABILITIES(id) ON UPDATE CASCADE
); );
CREATE TABLE USER_ASSIGNMENTS ( CREATE TABLE USER_ASSIGNMENTS (
eventID INTEGER NOT NULL, eventID INTEGER NOT NULL,
taskID INTEGER NOT NULL, taskID INTEGER NOT NULL,
userID INTEGER NOT NULL, userName varchar(64),
PRIMARY KEY (eventID, taskID), PRIMARY KEY (eventID, taskID),
FOREIGN KEY (userID) REFERENCES USERS(id) ON DELETE CASCADE, FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE,
FOREIGN KEY (taskID) REFERENCES TASKS(id) ON DELETE RESTRICT, FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE FOREIGN KEY (taskID) REFERENCES TASKS(id) ON UPDATE CASCADE
); );