commit 2c4fc7869a95b83d8fdaea8da525ddbd297bca96 Author: Patryk Hegenberg Date: Tue Nov 4 22:20:09 2025 +0100 feat: initial project commit diff --git a/backend/database.go b/backend/database.go new file mode 100644 index 0000000..8fe58c0 --- /dev/null +++ b/backend/database.go @@ -0,0 +1,178 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" +) + +func InitDB(filepath string) *sql.DB { + db, err := sql.Open("sqlite", filepath) + if err != nil { + log.Fatal(err) + } + + if err = db.Ping(); err != nil { + log.Fatal(err) + } + + createTables(db) + return db +} + +func createTables(db *sql.DB) { + queries := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_of_week INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + schedule_id INTEGER NOT NULL, + date TEXT NOT NULL, + type TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (schedule_id) REFERENCES schedules(id) + )`, + } + + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + log.Fatal(err) + } + } + + // Create default admin user (password: admin123) + // Hash generated with bcrypt + hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + fmt.Println(string(hash)) + _, err := db.Exec(` + INSERT OR IGNORE INTO users (id, username, password, is_admin) + VALUES (?, ?, ?, ?)`, + 1, "admin", string(hash), true, + ) + if err != nil { + log.Fatal(err) + } +} + +func GetUserByUsername(db *sql.DB, username string) (*User, error) { + user := &User{} + err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username). + Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin) + if err != nil { + return nil, err + } + return user, nil +} + +func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool) error { + _, err := db.Exec("INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", + username, hashedPassword, isAdmin) + return err +} + +func GetAllUsers(db *sql.DB) ([]User, error) { + rows, err := db.Query("SELECT id, username, is_admin FROM users") + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin); err != nil { + continue + } + users = append(users, u) + } + return users, nil +} + +func CreateSchedule(db *sql.DB, schedule *Schedule) error { + _, err := db.Exec("INSERT INTO schedules (day_of_week, start_time, end_time, type, title) VALUES (?, ?, ?, ?, ?)", + schedule.DayOfWeek, schedule.StartTime, schedule.EndTime, schedule.Type, schedule.Title) + return err +} + +func GetAllSchedules(db *sql.DB) ([]Schedule, error) { + rows, err := db.Query("SELECT id, day_of_week, start_time, end_time, type, title FROM schedules ORDER BY day_of_week, start_time") + if err != nil { + return nil, err + } + defer rows.Close() + + var schedules []Schedule + for rows.Next() { + var s Schedule + if err := rows.Scan(&s.ID, &s.DayOfWeek, &s.StartTime, &s.EndTime, &s.Type, &s.Title); err != nil { + continue + } + schedules = append(schedules, s) + } + return schedules, nil +} + +func DeleteSchedule(db *sql.DB, id int) error { + _, err := db.Exec("DELETE FROM schedules WHERE id = ?", id) + return err +} + +func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { + _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type) VALUES (?, ?, ?, ?)", + entry.UserID, entry.ScheduleID, entry.Date, entry.Type) + return err +} + +func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { + rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries WHERE user_id = ? ORDER BY date DESC, created_at DESC", + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []TimeEntry + for rows.Next() { + var e TimeEntry + if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { + continue + } + entries = append(entries, e) + } + return entries, nil +} + +func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { + rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries ORDER BY date DESC, created_at DESC") + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []TimeEntry + for rows.Next() { + var e TimeEntry + if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { + continue + } + entries = append(entries, e) + } + return entries, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..acf5f9a --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module school-timetracker + +go 1.25.3 + +require ( + golang.org/x/crypto v0.43.0 + modernc.org/sqlite v1.40.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..34771d9 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,51 @@ +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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ= +modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..ae59045 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,172 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "golang.org/x/crypto/bcrypt" +) + +type App struct { + DB *sql.DB +} + +func (app *App) LoginHandler(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := GetUserByUsername(app.DB, req.Username) + if err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + token, err := createToken(user.ID, user.Username, user.IsAdmin) + if err != nil { + http.Error(w, "Error creating token", http.StatusInternalServerError) + return + } + + response := LoginResponse{ + Token: token, + Username: user.Username, + IsAdmin: user.IsAdmin, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (app *App) GetSchedulesHandler(w http.ResponseWriter, r *http.Request) { + schedules, err := GetAllSchedules(app.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(schedules) +} + +func (app *App) CreateScheduleHandler(w http.ResponseWriter, r *http.Request) { + var schedule Schedule + if err := json.NewDecoder(r.Body).Decode(&schedule); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := CreateSchedule(app.DB, &schedule); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (app *App) DeleteScheduleHandler(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := DeleteSchedule(app.DB, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (app *App) CreateUserHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + IsAdmin bool `json:"is_admin"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Error hashing password", http.StatusInternalServerError) + return + } + + if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (app *App) GetUsersHandler(w http.ResponseWriter, r *http.Request) { + users, err := GetAllUsers(app.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Header.Get("X-User-ID") + userID, _ := strconv.Atoi(userIDStr) + + var entry TimeEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + entry.UserID = userID + + if err := CreateTimeEntry(app.DB, &entry); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (app *App) GetMyTimeEntriesHandler(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Header.Get("X-User-ID") + userID, _ := strconv.Atoi(userIDStr) + + entries, err := GetTimeEntriesByUser(app.DB, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func (app *App) GetAllTimeEntriesHandler(w http.ResponseWriter, r *http.Request) { + entries, err := GetAllTimeEntries(app.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..5ceb1bc --- /dev/null +++ b/backend/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "net/http" + "os" +) + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./timetracking.db" + } + + db := InitDB(dbPath) + defer db.Close() + + app := &App{DB: db} + + // Public routes + http.HandleFunc("/api/login", CORS(app.LoginHandler)) + + // Protected routes + http.HandleFunc("/api/schedules", CORS(AuthMiddleware(app.GetSchedulesHandler))) + http.HandleFunc("/api/time-entries", CORS(AuthMiddleware(app.CreateTimeEntryHandler))) + http.HandleFunc("/api/my-time-entries", CORS(AuthMiddleware(app.GetMyTimeEntriesHandler))) + + // Admin routes + http.HandleFunc("/api/admin/schedules", CORS(AdminMiddleware(app.CreateScheduleHandler))) + http.HandleFunc("/api/admin/schedules/delete", CORS(AdminMiddleware(app.DeleteScheduleHandler))) + http.HandleFunc("/api/admin/users", CORS(AdminMiddleware(app.CreateUserHandler))) + http.HandleFunc("/api/admin/users/list", CORS(AdminMiddleware(app.GetUsersHandler))) + http.HandleFunc("/api/admin/time-entries", CORS(AdminMiddleware(app.GetAllTimeEntriesHandler))) + + // Serve frontend + fs := http.FileServer(http.Dir("./static")) + http.Handle("/", fs) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/backend/middleware.go b/backend/middleware.go new file mode 100644 index 0000000..82d96bf --- /dev/null +++ b/backend/middleware.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +var jwtSecret = []byte("your-secret-key-change-in-production") + +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + Exp int64 `json:"exp"` +} + +func createToken(userID int, username string, isAdmin bool) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + IsAdmin: isAdmin, + Exp: time.Now().Add(24 * time.Hour).Unix(), + } + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + + payload, _ := json.Marshal(claims) + payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) + + message := header + "." + payloadEncoded + + h := hmac.New(sha256.New, jwtSecret) + h.Write([]byte(message)) + signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return message + "." + signature, nil +} + +func verifyToken(tokenString string) (*Claims, error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } + + message := parts[0] + "." + parts[1] + h := hmac.New(sha256.New, jwtSecret) + h.Write([]byte(message)) + expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + if parts[2] != expectedSignature { + return nil, fmt.Errorf("invalid signature") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + + if time.Now().Unix() > claims.Exp { + return nil, fmt.Errorf("token expired") + } + + return &claims, nil +} + +func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := verifyToken(tokenString) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID)) + r.Header.Set("X-Username", claims.Username) + r.Header.Set("X-Is-Admin", fmt.Sprintf("%t", claims.IsAdmin)) + + next(w, r) + } +} + +func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + isAdmin := r.Header.Get("X-Is-Admin") == "true" + if !isAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next(w, r) + }) +} + +func CORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next(w, r) + } +} + diff --git a/backend/models.go b/backend/models.go new file mode 100644 index 0000000..2ef8dd5 --- /dev/null +++ b/backend/models.go @@ -0,0 +1,41 @@ +package main + +import ( + "time" +) + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"-"` + IsAdmin bool `json:"is_admin"` +} + +type Schedule struct { + ID int `json:"id"` + DayOfWeek int `json:"day_of_week"` // 0=Monday, 4=Friday + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Type string `json:"type"` // "lesson" or "break" + Title string `json:"title"` +} + +type TimeEntry struct { + ID int `json:"id"` + UserID int `json:"user_id"` + ScheduleID int `json:"schedule_id"` + Date string `json:"date"` + Type string `json:"type"` // "lesson" or "break" + CreatedAt time.Time `json:"created_at"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` +} diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..825d8bc --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,23 @@ + + + + + + Schulzeit Erfassung + + + + +
+ + + + diff --git a/frontend/elm.json b/frontend/elm.json new file mode 100644 index 0000000..300f393 --- /dev/null +++ b/frontend/elm.json @@ -0,0 +1,25 @@ +{ + "type": "application", + "source-directories": ["src"], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0", + "elm/http": "2.0.0", + "elm/json": "1.1.3", + "elm/time": "1.0.0" + }, + "indirect": { + "elm/bytes": "1.0.8", + "elm/file": "1.0.5", + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.3" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm new file mode 100644 index 0000000..61dc176 --- /dev/null +++ b/frontend/src/Main.elm @@ -0,0 +1,950 @@ +module Main exposing (..) + +import Browser +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Http +import Json.Decode as Decode exposing (Decoder, field, int, string, bool, list) +import Json.Encode as Encode +import Time + + +-- MAIN + +main : Program () Model Msg +main = + Browser.element + { init = init + , update = update + , subscriptions = subscriptions + , view = view + } + + +-- MODEL + +type alias Model = + { page : Page + , username : String + , password : String + , token : Maybe String + , isAdmin : Bool + , schedules : List Schedule + , users : List User + , timeEntries : List TimeEntry + , selectedEntries : List Int + , currentDate : String + , newSchedule : NewSchedule + , newUser : NewUser + , error : Maybe String + } + +type Page + = LoginPage + | UserDashboard + | AdminDashboard + +type alias Schedule = + { id : Int + , dayOfWeek : Int + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + +type alias User = + { id : Int + , username : String + , isAdmin : Bool + } + +type alias TimeEntry = + { id : Int + , userId : Int + , scheduleId : Int + , date : String + , entryType : String + } + +type alias NewSchedule = + { dayOfWeek : String + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + +type alias NewUser = + { username : String + , password : String + , isAdmin : Bool + } + +init : () -> (Model, Cmd Msg) +init _ = + ( { page = LoginPage + , username = "" + , password = "" + , token = Nothing + , isAdmin = False + , schedules = [] + , users = [] + , timeEntries = [] + , selectedEntries = [] + , currentDate = "" + , newSchedule = NewSchedule "" "" "" "lesson" "" + , newUser = NewUser "" "" False + , error = Nothing + } + , Cmd.none + ) + + +-- UPDATE + +type Msg + = UpdateUsername String + | UpdatePassword String + | Login + | LoginResponse (Result Http.Error LoginResult) + | Logout + | FetchSchedules + | SchedulesReceived (Result Http.Error (List Schedule)) + | ToggleScheduleSelection Int + | SaveTimeEntries + | TimeEntriesSaved (Result Http.Error ()) + | UpdateNewScheduleDay String + | UpdateNewScheduleStart String + | UpdateNewScheduleEnd String + | UpdateNewScheduleType String + | UpdateNewScheduleTitle String + | CreateSchedule + | ScheduleCreated (Result Http.Error ()) + | DeleteSchedule Int + | ScheduleDeleted (Result Http.Error ()) + | UpdateNewUsername String + | UpdateNewPassword String + | UpdateNewUserAdmin Bool + | CreateUser + | UserCreated (Result Http.Error ()) + | FetchUsers + | UsersReceived (Result Http.Error (List User)) + | FetchAllTimeEntries + | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | UpdateCurrentDate String + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + UpdateUsername username -> + ({ model | username = username }, Cmd.none) + + UpdatePassword password -> + ({ model | password = password }, Cmd.none) + + Login -> + (model, loginRequest model.username model.password) + + LoginResponse (Ok result) -> + let + newPage = if result.isAdmin then AdminDashboard else UserDashboard + in + ({ model + | token = Just result.token + , isAdmin = result.isAdmin + , page = newPage + , error = Nothing + }, fetchSchedules (Just result.token)) + + LoginResponse (Err _) -> + ({ model | error = Just "Login fehlgeschlagen" }, Cmd.none) + + Logout -> + init () + + FetchSchedules -> + (model, fetchSchedules model.token) + + SchedulesReceived (Ok schedules) -> + ({ model | schedules = schedules }, Cmd.none) + + SchedulesReceived (Err _) -> + ({ model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none) + + ToggleScheduleSelection scheduleId -> + let + newSelected = + if List.member scheduleId model.selectedEntries then + List.filter (\id -> id /= scheduleId) model.selectedEntries + else + scheduleId :: model.selectedEntries + in + ({ model | selectedEntries = newSelected }, Cmd.none) + + SaveTimeEntries -> + case model.token of + Just token -> + (model, saveTimeEntries token model.selectedEntries model.currentDate) + Nothing -> + (model, Cmd.none) + + TimeEntriesSaved (Ok _) -> + ({ model | selectedEntries = [], error = Nothing }, Cmd.none) + + TimeEntriesSaved (Err _) -> + ({ model | error = Just "Fehler beim Speichern" }, Cmd.none) + + UpdateNewScheduleDay day -> + let + oldSchedule = model.newSchedule + newSchedule = { oldSchedule | dayOfWeek = day } + in + ({ model | newSchedule = newSchedule }, Cmd.none) + + UpdateNewScheduleStart time -> + let + oldSchedule = model.newSchedule + newSchedule = { oldSchedule | startTime = time } + in + ({ model | newSchedule = newSchedule }, Cmd.none) + + UpdateNewScheduleEnd time -> + let + oldSchedule = model.newSchedule + newSchedule = { oldSchedule | endTime = time } + in + ({ model | newSchedule = newSchedule }, Cmd.none) + + UpdateNewScheduleType scheduleType -> + let + oldSchedule = model.newSchedule + newSchedule = { oldSchedule | scheduleType = scheduleType } + in + ({ model | newSchedule = newSchedule }, Cmd.none) + + UpdateNewScheduleTitle title -> + let + oldSchedule = model.newSchedule + newSchedule = { oldSchedule | title = title } + in + ({ model | newSchedule = newSchedule }, Cmd.none) + + CreateSchedule -> + case model.token of + Just token -> + (model, createSchedule token model.newSchedule) + Nothing -> + (model, Cmd.none) + + ScheduleCreated (Ok _) -> + let + emptySchedule = NewSchedule "" "" "" "lesson" "" + in + ({ model | newSchedule = emptySchedule }, fetchSchedules model.token) + + ScheduleCreated (Err _) -> + ({ model | error = Just "Fehler beim Erstellen" }, Cmd.none) + + DeleteSchedule scheduleId -> + case model.token of + Just token -> + (model, deleteSchedule token scheduleId) + Nothing -> + (model, Cmd.none) + + ScheduleDeleted (Ok _) -> + (model, fetchSchedules model.token) + + ScheduleDeleted (Err _) -> + ({ model | error = Just "Fehler beim Löschen" }, Cmd.none) + + UpdateNewUsername username -> + let + oldUser = model.newUser + newUser = { oldUser | username = username } + in + ({ model | newUser = newUser }, Cmd.none) + + UpdateNewPassword password -> + let + oldUser = model.newUser + newUser = { oldUser | password = password } + in + ({ model | newUser = newUser }, Cmd.none) + + UpdateNewUserAdmin isAdmin -> + let + oldUser = model.newUser + newUser = { oldUser | isAdmin = isAdmin } + in + ({ model | newUser = newUser }, Cmd.none) + + CreateUser -> + case model.token of + Just token -> + (model, createUser token model.newUser) + Nothing -> + (model, Cmd.none) + + UserCreated (Ok _) -> + let + emptyUser = NewUser "" "" False + in + case model.token of + Just token -> + ({ model | newUser = emptyUser }, fetchUsers token) + Nothing -> + ({ model | error = Just "Kein Token vorhanden" }, Cmd.none) + -- ({ model | newUser = emptyUser }, fetchUsers model.token) + + UserCreated (Err _) -> + ({ model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none) + + FetchUsers -> + case model.token of + Just token -> + (model, fetchUsers token) + Nothing -> + (model, Cmd.none) + + UsersReceived (Ok users) -> + ({ model | users = users }, Cmd.none) + + UsersReceived (Err _) -> + ({ model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none) + + FetchAllTimeEntries -> + case model.token of + Just token -> + (model, fetchAllTimeEntries token) + Nothing -> + (model, Cmd.none) + + AllTimeEntriesReceived (Ok entries) -> + ({ model | timeEntries = entries }, Cmd.none) + + AllTimeEntriesReceived (Err _) -> + ({ model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none) + + UpdateCurrentDate date -> + ({ model | currentDate = date }, Cmd.none) + + +-- SUBSCRIPTIONS + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.none + + +-- VIEW + +view : Model -> Html Msg +view model = + div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model + + UserDashboard -> + viewUserDashboard model + + AdminDashboard -> + viewAdminDashboard model + ] + +viewLogin : Model -> Html Msg +viewLogin model = + section [ class "section" ] + [ div [ class "container" ] + [ div [ class "columns is-centered" ] + [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] + [ div [ class "box" ] + [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] + , case model.error of + Just err -> + div [ class "notification is-danger" ] [ text err ] + Nothing -> + text "" + , div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.username + , onInput UpdateUsername + ] [] + ] + ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.password + , onInput UpdatePassword + ] [] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-fullwidth" + , onClick Login + ] [ text "Anmelden" ] + ] + ] + ] + ] + ] + ] + ] + +viewUserDashboard : Model -> Html Msg +viewUserDashboard model = + div [] + [ nav [ class "navbar is-primary" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] + ] + ] + , div [ class "navbar-menu" ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ h2 [ class "title" ] [ text "Stundenplan" ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.currentDate + , onInput UpdateCurrentDate + ] [] + ] + ] + , viewScheduleGrid model + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-large is-fullwidth" + , onClick SaveTimeEntries + , disabled (List.isEmpty model.selectedEntries || String.isEmpty model.currentDate) + ] [ text "Speichern" ] + ] + ] + , case model.error of + Just err -> + div [ class "notification is-danger" ] [ text err ] + Nothing -> + text "" + ] + ] + ] + +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + ] + , div [ class "navbar-menu" ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ class "is-active" ] [ a [] [ text "Stundenplan" ] ] + , li [] [ a [ onClick FetchUsers ] [ text "Benutzer" ] ] + , li [] [ a [ onClick FetchAllTimeEntries ] [ text "Zeiteinträge" ] ] + ] + ] + , h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + , h2 [ class "title" ] [ text "Benutzer anlegen" ] + , viewUserForm model + , if not (List.isEmpty model.users) then + viewUserList model + else + text "" + , if not (List.isEmpty model.timeEntries) then + viewTimeEntriesList model + else + text "" + ] + ] + ] + +viewScheduleGrid : Model -> Html Msg +viewScheduleGrid model = + let + days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"] + + groupedSchedules = List.range 0 4 + |> List.map (\day -> + List.filter (\s -> s.dayOfWeek == day) model.schedules + ) + in + div [ class "table-container" ] + [ table [ class "table is-bordered is-fullwidth" ] + [ thead [] + [ tr [] (List.map (\day -> th [] [ text day ]) days) + ] + , tbody [] + [ tr [] + (List.map (viewDayColumn model) groupedSchedules) + ] + ] + ] + +viewDayColumn : Model -> List Schedule -> Html Msg +viewDayColumn model schedules = + td [ class "has-background-light" ] + (List.map (viewScheduleItem model) schedules) + +viewScheduleItem : Model -> Schedule -> Html Msg +viewScheduleItem model schedule = + let + isSelected = List.member schedule.id model.selectedEntries + boxClass = if isSelected then "box has-background-primary-light" else "box" + typeText = if schedule.scheduleType == "break" then " (Pause)" else "" + in + div + [ class boxClass + , onClick (ToggleScheduleSelection schedule.id) + , style "cursor" "pointer" + , style "margin-bottom" "0.5rem" + ] + [ p [ class "has-text-weight-bold" ] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [] [ text (schedule.title ++ typeText) ] + ] + +viewScheduleForm : Model -> Html Msg +viewScheduleForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Wochentag" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateNewScheduleDay ] + [ option [ value "" ] [ text "Wochentag wählen" ] + , option [ value "0" ] [ text "Montag" ] + , option [ value "1" ] [ text "Dienstag" ] + , option [ value "2" ] [ text "Mittwoch" ] + , option [ value "3" ] [ text "Donnerstag" ] + , option [ value "4" ] [ text "Freitag" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.startTime + , onInput UpdateNewScheduleStart + ] [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.endTime + , onInput UpdateNewScheduleEnd + ] [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateNewScheduleType, value model.newSchedule.scheduleType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Titel" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "z.B. Mathematik" + , value model.newSchedule.title + , onInput UpdateNewScheduleTitle + ] [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateSchedule ] [ text "Hinzufügen" ] + ] + ] + ] + +viewScheduleList : Model -> Html Msg +viewScheduleList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Tag" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [] [ text "Titel" ] + , th [] [ text "Aktion" ] + ] + ] + , tbody [] + (List.map viewScheduleRow model.schedules) + ] + ] + +viewScheduleRow : Schedule -> Html Msg +viewScheduleRow schedule = + let + dayName = case schedule.dayOfWeek of + 0 -> "Montag" + 1 -> "Dienstag" + 2 -> "Mittwoch" + 3 -> "Donnerstag" + 4 -> "Freitag" + _ -> "Unbekannt" + + typeName = if schedule.scheduleType == "break" then "Pause" else "Unterricht" + in + tr [] + [ td [] [ text dayName ] + , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , td [] [ text typeName ] + , td [] [ text schedule.title ] + , td [] + [ button + [ class "button is-small is-danger" + , onClick (DeleteSchedule schedule.id) + ] [ text "Löschen" ] + ] + ] + +viewUserForm : Model -> Html Msg +viewUserForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.newUser.username + , onInput UpdateNewUsername + ] [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.newUser.password + , onInput UpdateNewPassword + ] [] + ] + ] + ] + , div [ class "column is-narrow" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Admin" ] + , div [ class "control" ] + [ label [ class "checkbox" ] + [ input + [ type_ "checkbox" + , checked model.newUser.isAdmin + , onCheck UpdateNewUserAdmin + ] [] + , text " Admin-Rechte" + ] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] + ] + ] + ] + +viewUserList : Model -> Html Msg +viewUserList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Benutzer" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "ID" ] + , th [] [ text "Benutzername" ] + , th [] [ text "Rolle" ] + ] + ] + , tbody [] + (List.map viewUserRow model.users) + ] + ] + +viewUserRow : User -> Html Msg +viewUserRow user = + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] + ] + +viewTimeEntriesList : Model -> Html Msg +viewTimeEntriesList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Alle Zeiteinträge" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Benutzer ID" ] + , th [] [ text "Stundenplan ID" ] + , th [] [ text "Datum" ] + , th [] [ text "Typ" ] + ] + ] + , tbody [] + (List.map viewTimeEntryRow model.timeEntries) + ] + ] + +viewTimeEntryRow : TimeEntry -> Html Msg +viewTimeEntryRow entry = + tr [] + [ td [] [ text (String.fromInt entry.userId) ] + , td [] [ text (String.fromInt entry.scheduleId) ] + , td [] [ text entry.date ] + , td [] [ text entry.entryType ] + ] + + +-- HTTP + +type alias LoginResult = + { token : String + , username : String + , isAdmin : Bool + } + +loginRequest : String -> String -> Cmd Msg +loginRequest username password = + Http.post + { url = "/api/login" + , body = Http.jsonBody <| + Encode.object + [ ("username", Encode.string username) + , ("password", Encode.string password) + ] + , expect = Http.expectJson LoginResponse loginDecoder + } + +loginDecoder : Decoder LoginResult +loginDecoder = + Decode.map3 LoginResult + (field "token" string) + (field "username" string) + (field "is_admin" bool) + +fetchSchedules : Maybe String -> Cmd Msg +fetchSchedules maybeToken = + case maybeToken of + Just token -> + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/schedules" + , body = Http.emptyBody + , expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder) + , timeout = Nothing + , tracker = Nothing + } + Nothing -> + Cmd.none + +scheduleDecoder : Decoder Schedule +scheduleDecoder = + Decode.map6 Schedule + (field "id" int) + (field "day_of_week" int) + (field "start_time" string) + (field "end_time" string) + (field "type" string) + (field "title" string) + +saveTimeEntries : String -> List Int -> String -> Cmd Msg +saveTimeEntries token scheduleIds date = + let + requests = List.map (\scheduleId -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries" + , body = Http.jsonBody <| + Encode.object + [ ("schedule_id", Encode.int scheduleId) + , ("date", Encode.string date) + , ("type", Encode.string "lesson") + ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } + ) scheduleIds + in + case List.head requests of + Just cmd -> cmd + Nothing -> Cmd.none + +createSchedule : String -> NewSchedule -> Cmd Msg +createSchedule token schedule = + case String.toInt schedule.dayOfWeek of + Just day -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules" + , body = Http.jsonBody <| + Encode.object + [ ("day_of_week", Encode.int day) + , ("start_time", Encode.string schedule.startTime) + , ("end_time", Encode.string schedule.endTime) + , ("type", Encode.string schedule.scheduleType) + , ("title", Encode.string schedule.title) + ] + , expect = Http.expectWhatever ScheduleCreated + , timeout = Nothing + , tracker = Nothing + } + Nothing -> + Cmd.none + +deleteSchedule : String -> Int -> Cmd Msg +deleteSchedule token scheduleId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId + , body = Http.emptyBody + , expect = Http.expectWhatever ScheduleDeleted + , timeout = Nothing + , tracker = Nothing + } + +createUser : String -> NewUser -> Cmd Msg +createUser token user = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users" + , body = Http.jsonBody <| + Encode.object + [ ("username", Encode.string user.username) + , ("password", Encode.string user.password) + , ("is_admin", Encode.bool user.isAdmin) + ] + , expect = Http.expectWhatever UserCreated + , timeout = Nothing + , tracker = Nothing + } + +fetchUsers : String -> Cmd Msg +fetchUsers token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/list" + , body = Http.emptyBody + , expect = Http.expectJson UsersReceived (Decode.list userDecoder) + , timeout = Nothing + , tracker = Nothing + } + +userDecoder : Decoder User +userDecoder = + Decode.map3 User + (field "id" int) + (field "username" string) + (field "is_admin" bool) + +fetchAllTimeEntries : String -> Cmd Msg +fetchAllTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries" + , body = Http.emptyBody + , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + +timeEntryDecoder : Decoder TimeEntry +timeEntryDecoder = + Decode.map5 TimeEntry + (field "id" int) + (field "user_id" int) + (field "schedule_id" int) + (field "date" string) + (field "type" string)