diff --git a/Dockerfile b/Dockerfile index d8cc163..c755046 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,8 @@ RUN go mod download COPY backend/ ./ # Frontend-Build in static/ einhängen (wird per embed eingebettet) COPY --from=frontend-builder /app/frontend/dist/ ./static/ -RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o krafttrainer ./cmd/server +ARG VERSION=dev +RUN CGO_ENABLED=1 go build -ldflags="-s -w -X main.Version=${VERSION}" -o krafttrainer ./cmd/server # Stage 3: Minimales Runtime-Image FROM debian:bookworm-slim diff --git a/Makefile b/Makefile index 71cb21c..0f2215c 100755 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") + .PHONY: dev-backend dev-frontend build clean dev-backend: @@ -10,7 +12,7 @@ build: cd frontend && pnpm install && pnpm build find backend/static -not -name 'embed.go' -not -name '.gitkeep' -not -path backend/static -delete cp -r frontend/dist/* backend/static/ - cd backend && GO111MODULE=on CGO_ENABLED=1 go build -o ../krafttrainer ./cmd/server + cd backend && GO111MODULE=on CGO_ENABLED=1 go build -ldflags "-X main.Version=$(VERSION)" -o ../krafttrainer ./cmd/server clean: rm -f krafttrainer diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2e815dc..8c7d844 100755 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -12,6 +12,9 @@ import ( "krafttrainer/static" ) +// Version wird beim Build per ldflags gesetzt. +var Version = "dev" + func main() { // Datenbank initialisieren s, err := store.New("krafttrainer.db") @@ -30,6 +33,13 @@ func main() { mux := http.NewServeMux() h := handler.New(s) h.RegisterRoutes(mux) + h.RegisterImageRoutes(mux) + + // Version-Endpoint + mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"version":"` + Version + `"}`)) + }) // SPA-Fallback: statische Dateien aus embed.FS servieren mux.Handle("/", spaHandler(static.FS)) diff --git a/backend/go.mod b/backend/go.mod index 78fee1f..26a68b4 100755 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module krafttrainer go 1.26.1 require ( - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect - github.com/mattn/go-sqlite3 v1.14.37 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.37 ) diff --git a/backend/go.sum b/backend/go.sum index 274e921..3d1eb27 100755 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,4 +1,16 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 5e4579e..1dfe3b5 100755 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -38,6 +38,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet) // Sessions + mux.HandleFunc("GET /api/v1/sessions/active", h.handleGetActiveSession) mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession) mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions) mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession) diff --git a/backend/internal/handler/image_handler.go b/backend/internal/handler/image_handler.go new file mode 100644 index 0000000..55665ef --- /dev/null +++ b/backend/internal/handler/image_handler.go @@ -0,0 +1,135 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/google/uuid" +) + +const ( + maxImageSize = 5 << 20 // 5 MB + uploadDir = "uploads" +) + +func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) { + exerciseID, err := pathID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige ID") + return + } + + images, err := h.store.ListExerciseImages(exerciseID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Bilder") + return + } + writeJSON(w, http.StatusOK, images) +} + +func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) { + exerciseID, err := pathID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige ID") + return + } + + // Übung muss existieren + exercise, err := h.store.GetExercise(exerciseID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Prüfen der Übung") + return + } + if exercise == nil { + writeError(w, http.StatusNotFound, "Übung nicht gefunden") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) + if err := r.ParseMultipartForm(maxImageSize); err != nil { + writeError(w, http.StatusBadRequest, "Datei zu groß (max 5 MB)") + return + } + + file, header, err := r.FormFile("image") + if err != nil { + writeError(w, http.StatusBadRequest, "Kein Bild im Request") + return + } + defer file.Close() + + // Content-Type prüfen + ct := header.Header.Get("Content-Type") + if ct != "image/jpeg" { + writeError(w, http.StatusBadRequest, "Nur JPG-Bilder erlaubt") + return + } + + // Datei speichern + filename := uuid.New().String() + ".jpg" + destPath := filepath.Join(uploadDir, filename) + + if err := os.MkdirAll(uploadDir, 0755); err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Upload-Verzeichnisses") + return + } + + dst, err := os.Create(destPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Speichern der Datei") + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + os.Remove(destPath) + writeError(w, http.StatusInternalServerError, "Fehler beim Schreiben der Datei") + return + } + + img, err := h.store.CreateExerciseImage(exerciseID, filename) + if err != nil { + os.Remove(destPath) + writeError(w, http.StatusInternalServerError, "Fehler beim Speichern des Bildes") + return + } + writeJSON(w, http.StatusCreated, img) +} + +func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) { + imageID, err := pathID(r, "imageId") + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Bild-ID") + return + } + + filename, err := h.store.DeleteExerciseImage(imageID) + if err != nil { + writeError(w, http.StatusNotFound, "Bild nicht gefunden") + return + } + + // Datei vom Dateisystem löschen + os.Remove(filepath.Join(uploadDir, filename)) + + w.WriteHeader(http.StatusNoContent) +} + +// RegisterImageRoutes registriert die Bild-spezifischen Routen und den statischen File-Server. +func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/exercises/{id}/images", h.handleListImages) + mux.HandleFunc("POST /api/v1/exercises/{id}/images", h.handleUploadImage) + mux.HandleFunc("DELETE /api/v1/exercises/{id}/images/{imageId}", h.handleDeleteImage) + + // Bilder statisch servieren + mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", + http.FileServer(http.Dir(uploadDir)))) + + // uploads-Verzeichnis sicherstellen + os.MkdirAll(uploadDir, 0755) + + fmt.Println("Bild-Upload konfiguriert:", uploadDir) +} diff --git a/backend/internal/handler/middleware.go b/backend/internal/handler/middleware.go index f95722c..98f2c0e 100755 --- a/backend/internal/handler/middleware.go +++ b/backend/internal/handler/middleware.go @@ -19,7 +19,7 @@ func CORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "http://*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-User-ID") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go index 750565e..ff45ef5 100755 --- a/backend/internal/handler/session.go +++ b/backend/internal/handler/session.go @@ -7,6 +7,36 @@ import ( "strings" ) +func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + session, err := h.store.GetActiveSession(uid) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Suchen der aktiven Session") + return + } + if session == nil { + writeJSON(w, http.StatusOK, nil) + return + } + + // Exercises des Sets mitliefern + exercises, err := h.store.GetSetExercises(session.SetID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "session": session, + "exercises": exercises, + }) +} + func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -26,6 +56,10 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { session, err := h.store.CreateSession(uid, req.SetID) if err != nil { + if strings.Contains(err.Error(), "SESSION_OPEN") { + writeError(w, http.StatusConflict, "Es läuft bereits ein Training. Bitte zuerst beenden.") + return + } if strings.Contains(err.Error(), "existiert nicht") { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/backend/internal/handler/session_resume_test.go b/backend/internal/handler/session_resume_test.go new file mode 100644 index 0000000..559e3c4 --- /dev/null +++ b/backend/internal/handler/session_resume_test.go @@ -0,0 +1,262 @@ +package handler_test + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + _ "github.com/mattn/go-sqlite3" + migrate "krafttrainer/internal/migrate" + "krafttrainer/internal/handler" + "krafttrainer/internal/store" + "krafttrainer/migrations" +) + +// --------------------------------------------------------------------------- +// Test infrastructure +// --------------------------------------------------------------------------- + +func newHandlerWithStore(t *testing.T) (*handler.Handler, *store.Store) { + t.Helper() + + f, err := os.CreateTemp("", "krafttrainer-handler-test-*.db") + if err != nil { + t.Fatalf("create temp db file: %v", err) + } + f.Close() + dbPath := f.Name() + + s, err := store.New(dbPath) + if err != nil { + os.Remove(dbPath) + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { + s.Close() + os.Remove(dbPath) + }) + + if err := migrate.Run(s.DB(), migrations.FS); err != nil { + t.Fatalf("migrate.Run: %v", err) + } + return handler.New(s), s +} + +func seedUserH(t *testing.T, db *sql.DB, name string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name) + if err != nil { + t.Fatalf("seedUser %q: %v", name, err) + } + id, _ := res.LastInsertId() + return id +} + +func seedTrainingSetH(t *testing.T, db *sql.DB, name string, userID int64) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, name, userID) + if err != nil { + t.Fatalf("seedTrainingSet %q: %v", name, err) + } + id, _ := res.LastInsertId() + return id +} + +func seedExerciseH(t *testing.T, db *sql.DB, name string, userID int64) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`, + name, userID, + ) + if err != nil { + t.Fatalf("seedExercise %q: %v", name, err) + } + id, _ := res.LastInsertId() + return id +} + +func openSessionH(t *testing.T, db *sql.DB, setID, userID int64) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, setID, userID) + if err != nil { + t.Fatalf("openSession: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +// requestWithUserID builds a request for path with an optional X-User-ID header. +func requestWithUserID(method, path string, userIDHeader string) *http.Request { + r := httptest.NewRequest(method, path, nil) + if userIDHeader != "" { + r.Header.Set("X-User-ID", userIDHeader) + } + return r +} + +// decodeBody parses the JSON response body into dst. +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) { + t.Helper() + if err := json.NewDecoder(rec.Body).Decode(dst); err != nil { + t.Fatalf("decode response body: %v", err) + } +} + +// newMux registers all handler routes on a fresh ServeMux so that Go 1.22+ +// pattern matching (including the /active literal segment) is active. +func newMux(h *handler.Handler) *http.ServeMux { + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return mux +} + +// --------------------------------------------------------------------------- +// handleGetActiveSession tests +// --------------------------------------------------------------------------- + +func TestHandleGetActiveSession_MissingUserIDHeader(t *testing.T) { + h, _ := newHandlerWithStore(t) + mux := newMux(h) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", "")) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status: want 400, got %d", rec.Code) + } +} + +func TestHandleGetActiveSession_InvalidUserIDHeader(t *testing.T) { + h, _ := newHandlerWithStore(t) + mux := newMux(h) + + for _, bad := range []string{"not-a-number", "0", "-1"} { + t.Run(bad, func(t *testing.T) { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", bad)) + if rec.Code != http.StatusBadRequest { + t.Errorf("header %q: status want 400, got %d", bad, rec.Code) + } + }) + } +} + +func TestHandleGetActiveSession_ReturnsNullWhenNoActiveSession(t *testing.T) { + h, s := newHandlerWithStore(t) + mux := newMux(h) + userID := seedUserH(t, s.DB(), "NoSession") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", userID))) + + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", rec.Code) + } + + // writeJSON(w, 200, nil) encodes the Go nil interface value as JSON "null". + body := strings.TrimSpace(rec.Body.String()) + if body != "null" { + t.Errorf("body: want %q, got %q", "null", body) + } +} + +func TestHandleGetActiveSession_ReturnsSessionAndExercises(t *testing.T) { + h, s := newHandlerWithStore(t) + mux := newMux(h) + + uid := seedUserH(t, s.DB(), "ActiveUser") + setID := seedTrainingSetH(t, s.DB(), "Push Tag", uid) + exID := seedExerciseH(t, s.DB(), "Bankdrücken", uid) + + if _, err := s.DB().Exec( + `INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, 0)`, + setID, exID, + ); err != nil { + t.Fatalf("link exercise to set: %v", err) + } + + sessionID := openSessionH(t, s.DB(), setID, uid) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid))) + + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body) + } + + var resp struct { + Session struct { + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + SetName string `json:"set_name"` + } `json:"session"` + Exercises []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"exercises"` + } + decodeBody(t, rec, &resp) + + if resp.Session.ID != sessionID { + t.Errorf("session.id: want %d, got %d", sessionID, resp.Session.ID) + } + if resp.Session.SetID != setID { + t.Errorf("session.set_id: want %d, got %d", setID, resp.Session.SetID) + } + if resp.Session.SetName != "Push Tag" { + t.Errorf("session.set_name: want %q, got %q", "Push Tag", resp.Session.SetName) + } + if len(resp.Exercises) != 1 { + t.Fatalf("exercises: want 1, got %d", len(resp.Exercises)) + } + if resp.Exercises[0].ID != exID { + t.Errorf("exercise.id: want %d, got %d", exID, resp.Exercises[0].ID) + } + if resp.Exercises[0].Name != "Bankdrücken" { + t.Errorf("exercise.name: want %q, got %q", "Bankdrücken", resp.Exercises[0].Name) + } +} + +func TestHandleGetActiveSession_ExercisesEmptyWhenSetHasNone(t *testing.T) { + h, s := newHandlerWithStore(t) + mux := newMux(h) + + uid := seedUserH(t, s.DB(), "NoExUser") + setID := seedTrainingSetH(t, s.DB(), "Leeres Set", uid) + openSessionH(t, s.DB(), setID, uid) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid))) + + if rec.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body) + } + + var resp struct { + Exercises []json.RawMessage `json:"exercises"` + } + decodeBody(t, rec, &resp) + + if len(resp.Exercises) != 0 { + t.Errorf("exercises: want empty slice, got %d items", len(resp.Exercises)) + } +} + +func TestHandleGetActiveSession_ContentTypeIsJSON(t *testing.T) { + h, s := newHandlerWithStore(t) + mux := newMux(h) + + uid := seedUserH(t, s.DB(), "CTUser") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid))) + + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type: want application/json, got %q", ct) + } +} diff --git a/backend/internal/model/exercise.go b/backend/internal/model/exercise.go index 5c685c8..fb4a967 100755 --- a/backend/internal/model/exercise.go +++ b/backend/internal/model/exercise.go @@ -8,22 +8,24 @@ import ( // Exercise repräsentiert eine Kraftübung. type Exercise struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - MuscleGroup string `json:"muscle_group"` - WeightStepKg float64 `json:"weight_step_kg"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + MuscleGroup string `json:"muscle_group"` + WeightStepKg float64 `json:"weight_step_kg"` + ExerciseNumber *int `json:"exercise_number,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // CreateExerciseRequest enthält die Felder zum Anlegen einer Übung. type CreateExerciseRequest struct { - Name string `json:"name"` - Description string `json:"description"` - MuscleGroup string `json:"muscle_group"` - WeightStepKg *float64 `json:"weight_step_kg"` + Name string `json:"name"` + Description string `json:"description"` + MuscleGroup string `json:"muscle_group"` + WeightStepKg *float64 `json:"weight_step_kg"` + ExerciseNumber *int `json:"exercise_number,omitempty"` } // Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg. diff --git a/backend/internal/model/exercise_image.go b/backend/internal/model/exercise_image.go new file mode 100644 index 0000000..a60ecfc --- /dev/null +++ b/backend/internal/model/exercise_image.go @@ -0,0 +1,12 @@ +package model + +import "time" + +// ExerciseImage repräsentiert ein Bild einer Übung. +type ExerciseImage struct { + ID int64 `json:"id"` + ExerciseID int64 `json:"exercise_id"` + Filename string `json:"filename"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/store/exercise_store.go b/backend/internal/store/exercise_store.go index b03712a..e3a78fb 100755 --- a/backend/internal/store/exercise_store.go +++ b/backend/internal/store/exercise_store.go @@ -9,7 +9,7 @@ import ( // ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert. func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) { rows, err := s.db.Query(` - SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at + SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, created_at, updated_at FROM exercises WHERE deleted_at IS NULL AND user_id = ? @@ -26,7 +26,7 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model. var exercises []model.Exercise for rows.Next() { var e model.Exercise - if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt); err != nil { return nil, fmt.Errorf("Übung scannen: %w", err) } exercises = append(exercises, e) @@ -41,9 +41,9 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model. func (s *Store) GetExercise(id int64) (*model.Exercise, error) { var e model.Exercise err := s.db.QueryRow(` - SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at, deleted_at + SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, created_at, updated_at, deleted_at FROM exercises WHERE id = ?`, id, - ).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt) + ).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt) if err == sql.ErrNoRows { return nil, nil } @@ -56,9 +56,9 @@ func (s *Store) GetExercise(id int64) (*model.Exercise, error) { // CreateExercise legt eine neue Übung an und gibt sie zurück. func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { result, err := s.db.Exec(` - INSERT INTO exercises (name, description, muscle_group, weight_step_kg, user_id) - VALUES (?, ?, ?, ?, ?)`, - req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, userID, + INSERT INTO exercises (name, description, muscle_group, weight_step_kg, exercise_number, user_id) + VALUES (?, ?, ?, ?, ?, ?)`, + req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, userID, ) if err != nil { return nil, fmt.Errorf("Übung erstellen: %w", err) @@ -72,10 +72,10 @@ func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) ( func (s *Store) UpdateExercise(id, userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { result, err := s.db.Exec(` UPDATE exercises - SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, + SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, exercise_number = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, - req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id, userID, + req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, id, userID, ) if err != nil { return nil, fmt.Errorf("Übung aktualisieren: %w", err) diff --git a/backend/internal/store/image_store.go b/backend/internal/store/image_store.go new file mode 100644 index 0000000..2510af0 --- /dev/null +++ b/backend/internal/store/image_store.go @@ -0,0 +1,74 @@ +package store + +import ( + "fmt" + "krafttrainer/internal/model" +) + +// ListExerciseImages gibt alle Bilder einer Übung zurück. +func (s *Store) ListExerciseImages(exerciseID int64) ([]model.ExerciseImage, error) { + rows, err := s.db.Query(` + SELECT id, exercise_id, filename, sort_order, created_at + FROM exercise_images + WHERE exercise_id = ? + ORDER BY sort_order, id`, exerciseID, + ) + if err != nil { + return nil, fmt.Errorf("Bilder abfragen: %w", err) + } + defer rows.Close() + + var images []model.ExerciseImage + for rows.Next() { + var img model.ExerciseImage + if err := rows.Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt); err != nil { + return nil, fmt.Errorf("Bild scannen: %w", err) + } + images = append(images, img) + } + if images == nil { + images = []model.ExerciseImage{} + } + return images, rows.Err() +} + +// CreateExerciseImage speichert einen Bild-Eintrag. +func (s *Store) CreateExerciseImage(exerciseID int64, filename string) (*model.ExerciseImage, error) { + // sort_order = bisherige Anzahl + var count int + s.db.QueryRow(`SELECT COUNT(*) FROM exercise_images WHERE exercise_id = ?`, exerciseID).Scan(&count) + + result, err := s.db.Exec(` + INSERT INTO exercise_images (exercise_id, filename, sort_order) + VALUES (?, ?, ?)`, exerciseID, filename, count, + ) + if err != nil { + return nil, fmt.Errorf("Bild speichern: %w", err) + } + + id, _ := result.LastInsertId() + var img model.ExerciseImage + err = s.db.QueryRow(` + SELECT id, exercise_id, filename, sort_order, created_at + FROM exercise_images WHERE id = ?`, id, + ).Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt) + if err != nil { + return nil, fmt.Errorf("Bild abfragen: %w", err) + } + return &img, nil +} + +// DeleteExerciseImage löscht ein Bild und gibt den Dateinamen zurück. +func (s *Store) DeleteExerciseImage(imageID int64) (string, error) { + var filename string + err := s.db.QueryRow(`SELECT filename FROM exercise_images WHERE id = ?`, imageID).Scan(&filename) + if err != nil { + return "", fmt.Errorf("Bild nicht gefunden: %w", err) + } + + _, err = s.db.Exec(`DELETE FROM exercise_images WHERE id = ?`, imageID) + if err != nil { + return "", fmt.Errorf("Bild löschen: %w", err) + } + return filename, nil +} diff --git a/backend/internal/store/session_resume_test.go b/backend/internal/store/session_resume_test.go new file mode 100644 index 0000000..9f4ec9e --- /dev/null +++ b/backend/internal/store/session_resume_test.go @@ -0,0 +1,435 @@ +package store_test + +import ( + "database/sql" + "os" + "strings" + "testing" + + _ "github.com/mattn/go-sqlite3" + migrate "krafttrainer/internal/migrate" + "krafttrainer/internal/store" + "krafttrainer/migrations" +) + +// newTestStore creates a temporary SQLite database file, runs all migrations, +// and returns a fully initialised *store.Store. A t.Cleanup removes the temp +// file and closes the store after the test finishes. +// +// We cannot use ":memory:" directly because store.New appends query parameters +// to the path string with "?", which would corrupt a URI already containing "?". +// A temp file avoids this and provides the same isolation guarantee. +func newTestStore(t *testing.T) *store.Store { + t.Helper() + + // Create a temp file; store.New will open it by path. + f, err := os.CreateTemp("", "krafttrainer-test-*.db") + if err != nil { + t.Fatalf("create temp db file: %v", err) + } + f.Close() + dbPath := f.Name() + + s, err := store.New(dbPath) + if err != nil { + os.Remove(dbPath) + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { + s.Close() + os.Remove(dbPath) + }) + + if err := migrate.Run(s.DB(), migrations.FS); err != nil { + t.Fatalf("migrate.Run: %v", err) + } + return s +} + +// seedUser inserts a user row and returns the new id. +func seedUser(t *testing.T, db *sql.DB, name string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name) + if err != nil { + t.Fatalf("seedUser: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +// seedExercise inserts a minimal exercise row and returns the new id. +func seedExercise(t *testing.T, db *sql.DB, name string, userID int64) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`, + name, userID, + ) + if err != nil { + t.Fatalf("seedExercise %q: %v", name, err) + } + id, _ := res.LastInsertId() + return id +} + +// seedTrainingSet inserts a training set and returns its id. +func seedTrainingSet(t *testing.T, db *sql.DB, name string, userID int64) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, + name, userID, + ) + if err != nil { + t.Fatalf("seedTrainingSet %q: %v", name, err) + } + id, _ := res.LastInsertId() + return id +} + +// seedSetExercise links an exercise to a training set at the given position. +func seedSetExercise(t *testing.T, db *sql.DB, setID, exerciseID int64, position int) { + t.Helper() + _, err := db.Exec( + `INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, + setID, exerciseID, position, + ) + if err != nil { + t.Fatalf("seedSetExercise set=%d exercise=%d: %v", setID, exerciseID, err) + } +} + +// openSession inserts a session that has no ended_at (open). +func openSession(t *testing.T, db *sql.DB, setID, userID int64) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, + setID, userID, + ) + if err != nil { + t.Fatalf("openSession: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +// closedSession inserts a session with ended_at set. +func closedSession(t *testing.T, db *sql.DB, setID, userID int64) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO sessions (set_id, user_id, ended_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, + setID, userID, + ) + if err != nil { + t.Fatalf("closedSession: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +// --------------------------------------------------------------------------- +// GetActiveSession tests +// --------------------------------------------------------------------------- + +func TestGetActiveSession_NoOpenSession(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Alice") + + got, err := s.GetActiveSession(userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil, got session id=%d", got.ID) + } +} + +func TestGetActiveSession_OnlyClosedSessions(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Bob") + setID := seedTrainingSet(t, s.DB(), "Set A", userID) + closedSession(t, s.DB(), setID, userID) + + got, err := s.GetActiveSession(userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil for user with only closed sessions, got id=%d", got.ID) + } +} + +func TestGetActiveSession_ReturnsOpenSession(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Carol") + setID := seedTrainingSet(t, s.DB(), "Brust-Tag", userID) + sessionID := openSession(t, s.DB(), setID, userID) + + got, err := s.GetActiveSession(userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil session, got nil") + } + if got.ID != sessionID { + t.Errorf("session ID: want %d, got %d", sessionID, got.ID) + } + if got.SetID != setID { + t.Errorf("set ID: want %d, got %d", setID, got.SetID) + } + if got.SetName != "Brust-Tag" { + t.Errorf("set name: want %q, got %q", "Brust-Tag", got.SetName) + } + if got.EndedAt != nil { + t.Errorf("expected EndedAt to be nil for open session") + } +} + +func TestGetActiveSession_IsolatedByUser(t *testing.T) { + // An open session belonging to another user must not be returned. + s := newTestStore(t) + user1 := seedUser(t, s.DB(), "User1") + user2 := seedUser(t, s.DB(), "User2") + setID := seedTrainingSet(t, s.DB(), "Set", user1) + openSession(t, s.DB(), setID, user1) + + got, err := s.GetActiveSession(user2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil for user2, got session id=%d (belongs to user1)", got.ID) + } +} + +func TestGetActiveSession_ReturnsLatestWhenMultipleOpen(t *testing.T) { + // If (by some data anomaly) two open sessions exist, the latest one is + // returned (ORDER BY started_at DESC LIMIT 1). + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Dave") + setID := seedTrainingSet(t, s.DB(), "Set", userID) + + firstID := openSession(t, s.DB(), setID, userID) + secondID := openSession(t, s.DB(), setID, userID) + + got, err := s.GetActiveSession(userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected a session, got nil") + } + // The second insert has a higher id; SQLite CURRENT_TIMESTAMP resolution + // may produce equal timestamps, so we accept either the second or the one + // with the higher id (both reflect "latest"). + if got.ID != secondID && got.ID != firstID { + t.Errorf("unexpected session id %d (want %d or %d)", got.ID, firstID, secondID) + } +} + +// --------------------------------------------------------------------------- +// CreateSession / SESSION_OPEN guard tests +// --------------------------------------------------------------------------- + +func TestCreateSession_RejectsWhenOpenSessionExists(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Eve") + setID := seedTrainingSet(t, s.DB(), "Leg Day", userID) + + // Create a first session via the store so the guard logic runs. + first, err := s.CreateSession(userID, setID) + if err != nil { + t.Fatalf("first CreateSession: %v", err) + } + if first == nil { + t.Fatal("first CreateSession returned nil") + } + + // A second call must be rejected with SESSION_OPEN. + _, err = s.CreateSession(userID, setID) + if err == nil { + t.Fatal("expected SESSION_OPEN error, got nil") + } + if !strings.Contains(err.Error(), "SESSION_OPEN") { + t.Errorf("expected error to contain SESSION_OPEN, got: %v", err) + } +} + +func TestCreateSession_AllowsNewSessionAfterClose(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Frank") + setID := seedTrainingSet(t, s.DB(), "Push", userID) + + first, err := s.CreateSession(userID, setID) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + _, err = s.EndSession(first.ID, userID, "") + if err != nil { + t.Fatalf("EndSession: %v", err) + } + + second, err := s.CreateSession(userID, setID) + if err != nil { + t.Fatalf("CreateSession after close: %v", err) + } + if second == nil { + t.Fatal("expected new session, got nil") + } + if second.ID == first.ID { + t.Errorf("expected a new session ID, got the same id=%d", second.ID) + } +} + +func TestCreateSession_OpenSessionOfOtherUserDoesNotBlock(t *testing.T) { + // An open session for user1 must not prevent user2 from starting one. + s := newTestStore(t) + user1 := seedUser(t, s.DB(), "Greg") + user2 := seedUser(t, s.DB(), "Hanna") + set1 := seedTrainingSet(t, s.DB(), "Set1", user1) + set2 := seedTrainingSet(t, s.DB(), "Set2", user2) + + if _, err := s.CreateSession(user1, set1); err != nil { + t.Fatalf("CreateSession user1: %v", err) + } + + sess2, err := s.CreateSession(user2, set2) + if err != nil { + t.Fatalf("CreateSession user2 should succeed: %v", err) + } + if sess2 == nil { + t.Fatal("expected session for user2, got nil") + } +} + +func TestCreateSession_ErrorOnNonExistentSet(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Iris") + + _, err := s.CreateSession(userID, 9999) + if err == nil { + t.Fatal("expected error for non-existent set, got nil") + } + if !strings.Contains(err.Error(), "existiert nicht") { + t.Errorf("expected 'existiert nicht' in error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// GetSetExercises tests +// --------------------------------------------------------------------------- + +func TestGetSetExercises_EmptySet(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Jack") + setID := seedTrainingSet(t, s.DB(), "Empty Set", userID) + + exercises, err := s.GetSetExercises(setID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(exercises) != 0 { + t.Errorf("expected 0 exercises, got %d", len(exercises)) + } +} + +func TestGetSetExercises_ReturnsExercisesInPositionOrder(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Kira") + setID := seedTrainingSet(t, s.DB(), "Full Body", userID) + + e1 := seedExercise(t, s.DB(), "Bankdrücken", userID) + e2 := seedExercise(t, s.DB(), "Kniebeugen", userID) + e3 := seedExercise(t, s.DB(), "Kreuzheben", userID) + + // Insert in non-sequential position order to verify ORDER BY position. + seedSetExercise(t, s.DB(), setID, e3, 2) + seedSetExercise(t, s.DB(), setID, e1, 0) + seedSetExercise(t, s.DB(), setID, e2, 1) + + exercises, err := s.GetSetExercises(setID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(exercises) != 3 { + t.Fatalf("expected 3 exercises, got %d", len(exercises)) + } + wantOrder := []int64{e1, e2, e3} + for i, ex := range exercises { + if ex.ID != wantOrder[i] { + t.Errorf("position %d: want exercise id=%d, got id=%d", i, wantOrder[i], ex.ID) + } + } +} + +func TestGetSetExercises_ExcludesSoftDeletedExercises(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Leo") + setID := seedTrainingSet(t, s.DB(), "Set", userID) + + active := seedExercise(t, s.DB(), "Aktive Übung", userID) + deleted := seedExercise(t, s.DB(), "Gelöschte Übung", userID) + + seedSetExercise(t, s.DB(), setID, active, 0) + seedSetExercise(t, s.DB(), setID, deleted, 1) + + // Soft-delete the second exercise. + if _, err := s.DB().Exec(`UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`, deleted); err != nil { + t.Fatalf("soft-delete exercise: %v", err) + } + + exercises, err := s.GetSetExercises(setID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(exercises) != 1 { + t.Fatalf("expected 1 exercise, got %d", len(exercises)) + } + if exercises[0].ID != active { + t.Errorf("expected active exercise id=%d, got id=%d", active, exercises[0].ID) + } +} + +func TestGetSetExercises_NonExistentSet(t *testing.T) { + s := newTestStore(t) + + exercises, err := s.GetSetExercises(9999) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(exercises) != 0 { + t.Errorf("expected 0 exercises for non-existent set, got %d", len(exercises)) + } +} + +func TestGetSetExercises_PopulatesAllFields(t *testing.T) { + s := newTestStore(t) + userID := seedUser(t, s.DB(), "Mia") + setID := seedTrainingSet(t, s.DB(), "Detail Set", userID) + exID := seedExercise(t, s.DB(), "Rudern", userID) + seedSetExercise(t, s.DB(), setID, exID, 0) + + exercises, err := s.GetSetExercises(setID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(exercises) != 1 { + t.Fatalf("expected 1 exercise, got %d", len(exercises)) + } + ex := exercises[0] + if ex.ID != exID { + t.Errorf("ID: want %d, got %d", exID, ex.ID) + } + if ex.Name != "Rudern" { + t.Errorf("Name: want %q, got %q", "Rudern", ex.Name) + } + if ex.MuscleGroup != "brust" { + t.Errorf("MuscleGroup: want %q, got %q", "brust", ex.MuscleGroup) + } + if ex.WeightStepKg != 2.5 { + t.Errorf("WeightStepKg: want 2.5, got %f", ex.WeightStepKg) + } + if ex.CreatedAt.IsZero() { + t.Error("CreatedAt must not be zero") + } +} diff --git a/backend/internal/store/session_store.go b/backend/internal/store/session_store.go index bcc1a7c..af9361d 100755 --- a/backend/internal/store/session_store.go +++ b/backend/internal/store/session_store.go @@ -8,9 +8,19 @@ import ( ) // CreateSession startet eine neue Trainingseinheit für einen Nutzer. +// Gibt einen Fehler zurück wenn noch eine offene Session existiert. func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) { + // Prüfe ob bereits eine offene Session existiert + active, err := s.GetActiveSession(userID) + if err != nil { + return nil, fmt.Errorf("Aktive Session prüfen: %w", err) + } + if active != nil { + return nil, fmt.Errorf("SESSION_OPEN: Es läuft bereits ein Training (%s)", active.SetName) + } + var setName string - err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName) + err = s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName) if err == sql.ErrNoRows { return nil, fmt.Errorf("Set %d existiert nicht", setID) } @@ -96,6 +106,51 @@ func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session, return sessions, rows.Err() } +// GetActiveSession gibt die offene Session eines Nutzers zurück (falls vorhanden). +func (s *Store) GetActiveSession(userID int64) (*model.Session, error) { + var sessionID int64 + err := s.db.QueryRow(` + SELECT id FROM sessions + WHERE user_id = ? AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1`, userID, + ).Scan(&sessionID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("Aktive Session suchen: %w", err) + } + return s.GetSession(sessionID) +} + +// GetSetExercises gibt die Übungen eines Training-Sets zurück. +func (s *Store) GetSetExercises(setID int64) ([]model.Exercise, error) { + rows, err := s.db.Query(` + SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.exercise_number, e.created_at, e.updated_at + FROM exercises e + JOIN set_exercises se ON se.exercise_id = e.id + WHERE se.set_id = ? AND e.deleted_at IS NULL + ORDER BY se.position`, setID, + ) + if err != nil { + return nil, fmt.Errorf("Set-Übungen abfragen: %w", err) + } + defer rows.Close() + + var exercises []model.Exercise + for rows.Next() { + var e model.Exercise + if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, fmt.Errorf("Übung scannen: %w", err) + } + exercises = append(exercises, e) + } + if exercises == nil { + exercises = []model.Exercise{} + } + return exercises, rows.Err() +} + // CreateLog fügt einen Satz zu einer offenen Session hinzu. func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) { if err := s.checkSessionOpen(sessionID); err != nil { diff --git a/backend/migrations/005_add_exercise_number.down.sql b/backend/migrations/005_add_exercise_number.down.sql new file mode 100644 index 0000000..00ab5e0 --- /dev/null +++ b/backend/migrations/005_add_exercise_number.down.sql @@ -0,0 +1 @@ +ALTER TABLE exercises DROP COLUMN exercise_number; diff --git a/backend/migrations/005_add_exercise_number.up.sql b/backend/migrations/005_add_exercise_number.up.sql new file mode 100644 index 0000000..495f221 --- /dev/null +++ b/backend/migrations/005_add_exercise_number.up.sql @@ -0,0 +1,24 @@ +ALTER TABLE exercises ADD COLUMN exercise_number INTEGER; + +-- Trainingsplan krafttraining2026: Übungsnummern (UF#) setzen +UPDATE exercises SET exercise_number = 32 WHERE name = 'Bankdrücken sitzend' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 94 WHERE name = 'Trizepsdrücken Kabel (Pushdown)' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 35 WHERE name = 'Schrägbankdrücken einarmig' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 92 WHERE name = 'Trizepsstrecken überkopf' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 36 WHERE name = 'Fliegende stehend' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 99 WHERE name = 'Trizeps-Kickbacks einarmig' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 60 WHERE name = 'Schulterdrücken sitzend' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 66 WHERE name = 'Seitheben' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 42 WHERE name = 'Latzug zur Brust (Obergriff)' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 87 WHERE name = 'Bizeps-Curls mit Curl-Stange' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 45 WHERE name = 'Latzug Untergriff' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 83 WHERE name = 'Hammer-Curls' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 53 WHERE name = 'Rudern stehend' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 89 WHERE name = 'Konzentrations-Curls einarmig' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 54 WHERE name = 'Einarmiges Rudern Kabel' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 81 WHERE name = '21er-Curls (Finisher)' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 106 WHERE name = 'Crunches am Kabel' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 110 WHERE name = 'Rückenstrecker sitzend' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 107 WHERE name = 'Schräge Crunches' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 109 WHERE name = 'Seitbeugen einarmig' AND deleted_at IS NULL; +UPDATE exercises SET exercise_number = 14 WHERE name = 'Hüftbeugen am Kabel' AND deleted_at IS NULL; diff --git a/backend/migrations/006_add_exercise_images.down.sql b/backend/migrations/006_add_exercise_images.down.sql new file mode 100644 index 0000000..1ae75de --- /dev/null +++ b/backend/migrations/006_add_exercise_images.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS exercise_images; diff --git a/backend/migrations/006_add_exercise_images.up.sql b/backend/migrations/006_add_exercise_images.up.sql new file mode 100644 index 0000000..1346e25 --- /dev/null +++ b/backend/migrations/006_add_exercise_images.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE exercise_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id INTEGER NOT NULL REFERENCES exercises(id), + filename TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_exercise_images_exercise_id ON exercise_images(exercise_id); diff --git a/docker-compose.yml b/docker-compose.yml index 83023db..ee7e179 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: krafttrainer: - build: . + build: + context: . + args: + VERSION: "${VERSION:-dev}" ports: - "8090:8090" volumes: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 97cdb78..797dfa5 100755 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import type { Exercise, + ExerciseImage, TrainingSet, Session, SessionLog, @@ -100,6 +101,37 @@ export const api = { return request(`/api/v1/exercises/${id}/last-log`); }, + listImages(id: number): Promise { + return request(`/api/v1/exercises/${id}/images`); + }, + + async uploadImage(id: number, file: File): Promise { + const formData = new FormData(); + formData.append('image', file); + + const headers: Record = {}; + const uid = getActiveUserId(); + if (uid) headers['X-User-ID'] = uid; + + const res = await fetch(`/api/v1/exercises/${id}/images`, { + method: 'POST', + headers, + body: formData, + }); + + const data = await res.json(); + if (!res.ok) { + throw new ApiError(res.status, data.error || 'Upload fehlgeschlagen'); + } + return data as ExerciseImage; + }, + + deleteImage(exerciseId: number, imageId: number): Promise { + return request(`/api/v1/exercises/${exerciseId}/images/${imageId}`, { + method: 'DELETE', + }); + }, + history(id: number, limit?: number): Promise { const params = new URLSearchParams(); if (limit) params.set('limit', String(limit)); @@ -137,6 +169,10 @@ export const api = { }, sessions: { + active(): Promise<{ session: Session; exercises: Exercise[] } | null> { + return request<{ session: Session; exercises: Exercise[] } | null>('/api/v1/sessions/active'); + }, + create(data: CreateSessionRequest): Promise { return request('/api/v1/sessions', { method: 'POST', @@ -202,4 +238,8 @@ export const api = { return request('/api/v1/stats/overview'); }, }, + + version(): Promise<{ version: string }> { + return request<{ version: string }>('/api/v1/version'); + }, }; diff --git a/frontend/src/components/exercises/ExerciseCard.tsx b/frontend/src/components/exercises/ExerciseCard.tsx index ad960ac..fe3f6aa 100755 --- a/frontend/src/components/exercises/ExerciseCard.tsx +++ b/frontend/src/components/exercises/ExerciseCard.tsx @@ -15,7 +15,12 @@ export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps)
-

{exercise.name}

+

+ {exercise.exercise_number != null && ( + #{exercise.exercise_number} + )} + {exercise.name} +

{exercise.description && (

{exercise.description}

)} diff --git a/frontend/src/components/exercises/ExerciseForm.tsx b/frontend/src/components/exercises/ExerciseForm.tsx index 24a57f3..75d112c 100755 --- a/frontend/src/components/exercises/ExerciseForm.tsx +++ b/frontend/src/components/exercises/ExerciseForm.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types'; import { MUSCLE_GROUPS } from '../../types'; +import { ImageGallery } from './ImageGallery'; interface ExerciseFormProps { exercise?: Exercise | null; @@ -13,6 +14,7 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps const [description, setDescription] = useState(''); const [muscleGroup, setMuscleGroup] = useState('brust'); const [weightStep, setWeightStep] = useState(2.5); + const [exerciseNumber, setExerciseNumber] = useState(''); useEffect(() => { if (exercise) { @@ -20,21 +22,25 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps setDescription(exercise.description); setMuscleGroup(exercise.muscle_group); setWeightStep(exercise.weight_step_kg); + setExerciseNumber(exercise.exercise_number != null ? String(exercise.exercise_number) : ''); } else { setName(''); setDescription(''); setMuscleGroup('brust'); setWeightStep(2.5); + setExerciseNumber(''); } }, [exercise]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + const num = exerciseNumber.trim() ? parseInt(exerciseNumber.trim(), 10) : undefined; onSubmit({ name: name.trim(), description: description.trim(), muscle_group: muscleGroup, weight_step_kg: weightStep, + exercise_number: Number.isFinite(num) ? num : undefined, }); }; @@ -58,6 +64,18 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps />
+
+ + setExerciseNumber(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]" + placeholder="Optional" + min={1} + /> +
+