Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
86
backend/internal/handler/exercise.go
Executable file
86
backend/internal/handler/exercise.go
Executable file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||
muscleGroup := r.URL.Query().Get("muscle_group")
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
exercises, err := h.store.ListExercises(muscleGroup, query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exercises)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateExerciseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.CreateExercise(&req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, exercise)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateExerciseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.UpdateExercise(id, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung")
|
||||
return
|
||||
}
|
||||
if exercise == nil {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exercise)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteExercise(id)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen der Übung")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
83
backend/internal/handler/handler.go
Executable file
83
backend/internal/handler/handler.go
Executable file
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"krafttrainer/internal/store"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Handler bündelt alle HTTP-Handler und hält eine Referenz auf den Store.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// New erstellt einen neuen Handler.
|
||||
func New(store *store.Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
|
||||
// RegisterRoutes registriert alle API-Routen am ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Exercises
|
||||
mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises)
|
||||
mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise)
|
||||
mux.HandleFunc("PUT /api/v1/exercises/{id}", h.handleUpdateExercise)
|
||||
mux.HandleFunc("DELETE /api/v1/exercises/{id}", h.handleDeleteExercise)
|
||||
|
||||
// Training Sets
|
||||
mux.HandleFunc("GET /api/v1/sets", h.handleListSets)
|
||||
mux.HandleFunc("POST /api/v1/sets", h.handleCreateSet)
|
||||
mux.HandleFunc("PUT /api/v1/sets/{id}", h.handleUpdateSet)
|
||||
mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
|
||||
|
||||
// Sessions
|
||||
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)
|
||||
mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession)
|
||||
|
||||
// Session Logs
|
||||
mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog)
|
||||
mux.HandleFunc("PUT /api/v1/sessions/{id}/logs/{logId}", h.handleUpdateLog)
|
||||
mux.HandleFunc("DELETE /api/v1/sessions/{id}/logs/{logId}", h.handleDeleteLog)
|
||||
|
||||
// Stats
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/last-log", h.handleGetLastLog)
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/history", h.handleGetExerciseHistory)
|
||||
mux.HandleFunc("GET /api/v1/stats/overview", h.handleGetStatsOverview)
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, defaultVal int) int {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return n
|
||||
}
|
||||
52
backend/internal/handler/middleware.go
Executable file
52
backend/internal/handler/middleware.go
Executable file
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain wendet Middlewares in der angegebenen Reihenfolge an.
|
||||
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
h = middlewares[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// CORS erlaubt Cross-Origin-Requests vom Vite Dev-Server.
|
||||
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")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequestLogger loggt eingehende Requests mit Dauer.
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// Recoverer fängt Panics in Handlern ab und gibt 500 zurück.
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Interner Serverfehler")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
190
backend/internal/handler/session.go
Executable file
190
backend/internal/handler/session.go
Executable file
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateSessionRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if req.SetID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "set_id ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.CreateSession(req.SetID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Starten der Session")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 20)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
|
||||
sessions, err := h.store.ListSessions(limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeError(w, http.StatusNotFound, "Session nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
// Body ist optional
|
||||
decodeJSON(r, &body)
|
||||
|
||||
session, err := h.store.EndSession(id, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeError(w, http.StatusNotFound, "Session nicht gefunden oder bereits beendet")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateLogRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log, err := h.store.CreateLog(sessionID, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNIQUE_VIOLATION") {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Loggen des Satzes")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, log)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
logID, err := pathID(r, "logId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.UpdateLogRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log, err := h.store.UpdateLog(sessionID, logID, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Satzes")
|
||||
return
|
||||
}
|
||||
if log == nil {
|
||||
writeError(w, http.StatusNotFound, "Log nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, log)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
logID, err := pathID(r, "logId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteLog(sessionID, logID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Log nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Satzes")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
47
backend/internal/handler/stats.go
Executable file
47
backend/internal/handler/stats.go
Executable file
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
lastLog, err := h.store.GetLastLog(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs")
|
||||
return
|
||||
}
|
||||
if lastLog == nil {
|
||||
writeError(w, http.StatusNotFound, "Noch kein Log für diese Übung")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lastLog)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
|
||||
logs, err := h.store.GetExerciseHistory(id, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, logs)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
overview, err := h.store.GetStatsOverview()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, overview)
|
||||
}
|
||||
92
backend/internal/handler/training_set.go
Executable file
92
backend/internal/handler/training_set.go
Executable file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
sets, err := h.store.ListSets()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sets)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateSetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.CreateSet(&req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Sets")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, set)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.UpdateSetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.UpdateSet(id, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Sets")
|
||||
return
|
||||
}
|
||||
if set == nil {
|
||||
writeError(w, http.StatusNotFound, "Set nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, set)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteSet(id)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Set nicht gefunden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Sets")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
36
backend/internal/migrate/migrate.go
Executable file
36
backend/internal/migrate/migrate.go
Executable file
@@ -0,0 +1,36 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
// Run führt alle ausstehenden Migrationen aus.
|
||||
// migrationsFS muss die Migrations-Dateien enthalten.
|
||||
func Run(db *sql.DB, migrationsFS fs.FS) error {
|
||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrations-Treiber erstellen: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationsFS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrations-Quelle erstellen: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrator erstellen: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("Migrationen ausführen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
backend/internal/model/exercise.go
Executable file
47
backend/internal/model/exercise.go
Executable file
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg.
|
||||
func (r *CreateExerciseRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if !ValidMuscleGroup(r.MuscleGroup) {
|
||||
return errors.New("Ungültige Muskelgruppe")
|
||||
}
|
||||
if r.WeightStepKg != nil {
|
||||
if *r.WeightStepKg <= 0 {
|
||||
return errors.New("Gewichtsschritt muss > 0 sein")
|
||||
}
|
||||
} else {
|
||||
def := 2.5
|
||||
r.WeightStepKg = &def
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
backend/internal/model/session.go
Executable file
19
backend/internal/model/session.go
Executable file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Session repräsentiert eine Trainingseinheit.
|
||||
type Session struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
SetName string `json:"set_name"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Note string `json:"note"`
|
||||
Logs []SessionLog `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSessionRequest enthält die Felder zum Starten einer Session.
|
||||
type CreateSessionRequest struct {
|
||||
SetID int64 `json:"set_id"`
|
||||
}
|
||||
76
backend/internal/model/session_log.go
Executable file
76
backend/internal/model/session_log.go
Executable file
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionLog repräsentiert einen einzelnen Satz innerhalb einer Session.
|
||||
type SessionLog struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID int64 `json:"session_id"`
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
ExerciseName string `json:"exercise_name"`
|
||||
SetNumber int `json:"set_number"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
Note string `json:"note"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
}
|
||||
|
||||
// CreateLogRequest enthält die Felder zum Loggen eines Satzes.
|
||||
type CreateLogRequest struct {
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
SetNumber int `json:"set_number"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Validate prüft den Request.
|
||||
func (r *CreateLogRequest) Validate() error {
|
||||
if r.SetNumber < 1 {
|
||||
return errors.New("Satznummer muss ≥ 1 sein")
|
||||
}
|
||||
if r.WeightKg < 0 || r.WeightKg > 999 {
|
||||
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
|
||||
}
|
||||
if r.Reps < 0 || r.Reps > 999 {
|
||||
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLogRequest enthält die Felder zum Korrigieren eines Satzes.
|
||||
type UpdateLogRequest struct {
|
||||
WeightKg *float64 `json:"weight_kg"`
|
||||
Reps *int `json:"reps"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
// Validate prüft den Request.
|
||||
func (r *UpdateLogRequest) Validate() error {
|
||||
if r.WeightKg != nil && (*r.WeightKg < 0 || *r.WeightKg > 999) {
|
||||
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
|
||||
}
|
||||
if r.Reps != nil && (*r.Reps < 0 || *r.Reps > 999) {
|
||||
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastLogResponse enthält die letzten Werte einer Übung.
|
||||
type LastLogResponse struct {
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
}
|
||||
|
||||
// ExerciseStats enthält aggregierte Statistiken einer Übung.
|
||||
type ExerciseStats struct {
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
ExerciseName string `json:"exercise_name"`
|
||||
MaxWeightKg float64 `json:"max_weight_kg"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
TotalSets int `json:"total_sets"`
|
||||
LastTrained string `json:"last_trained"`
|
||||
}
|
||||
52
backend/internal/model/training_set.go
Executable file
52
backend/internal/model/training_set.go
Executable file
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrainingSet ist eine benannte Zusammenstellung von Übungen.
|
||||
type TrainingSet struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSetRequest enthält die Felder zum Anlegen eines Sets.
|
||||
type CreateSetRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExerciseIDs []int64 `json:"exercise_ids"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request.
|
||||
func (r *CreateSetRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if len(r.ExerciseIDs) == 0 {
|
||||
return errors.New("Mindestens eine Übung erforderlich")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSetRequest enthält die Felder zum Aktualisieren eines Sets.
|
||||
type UpdateSetRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExerciseIDs []int64 `json:"exercise_ids"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request.
|
||||
func (r *UpdateSetRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if len(r.ExerciseIDs) == 0 {
|
||||
return errors.New("Mindestens eine Übung erforderlich")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
backend/internal/model/validation.go
Executable file
28
backend/internal/model/validation.go
Executable file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
// Gültige Muskelgruppen für Übungen.
|
||||
var muscleGroups = map[string]bool{
|
||||
"brust": true,
|
||||
"ruecken": true,
|
||||
"schultern": true,
|
||||
"bizeps": true,
|
||||
"trizeps": true,
|
||||
"beine": true,
|
||||
"bauch": true,
|
||||
"ganzkoerper": true,
|
||||
"sonstiges": true,
|
||||
}
|
||||
|
||||
// ValidMuscleGroup prüft ob die übergebene Muskelgruppe gültig ist.
|
||||
func ValidMuscleGroup(mg string) bool {
|
||||
return muscleGroups[mg]
|
||||
}
|
||||
|
||||
// MuscleGroups gibt alle gültigen Muskelgruppen zurück.
|
||||
func MuscleGroups() []string {
|
||||
groups := make([]string, 0, len(muscleGroups))
|
||||
for g := range muscleGroups {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
105
backend/internal/store/exercise_store.go
Executable file
105
backend/internal/store/exercise_store.go
Executable file
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert.
|
||||
func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at
|
||||
FROM exercises
|
||||
WHERE deleted_at IS NULL
|
||||
AND (muscle_group = ? OR ? = '')
|
||||
AND (name LIKE '%' || ? || '%' OR ? = '')
|
||||
ORDER BY name`,
|
||||
muscleGroup, muscleGroup, query, query,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ü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.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()
|
||||
}
|
||||
|
||||
// GetExercise gibt eine einzelne Übung zurück.
|
||||
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
|
||||
FROM exercises WHERE id = ?`, id,
|
||||
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung abfragen: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// CreateExercise legt eine neue Übung an und gibt sie zurück.
|
||||
func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO exercises (name, description, muscle_group, weight_step_kg)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.GetExercise(id)
|
||||
}
|
||||
|
||||
// UpdateExercise aktualisiert eine Übung und gibt sie zurück.
|
||||
func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung aktualisieren: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.GetExercise(id)
|
||||
}
|
||||
|
||||
// SoftDeleteExercise markiert eine Übung als gelöscht.
|
||||
func (s *Store) SoftDeleteExercise(id int64) error {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Übung löschen: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
269
backend/internal/store/session_store.go
Executable file
269
backend/internal/store/session_store.go
Executable file
@@ -0,0 +1,269 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateSession startet eine neue Trainingseinheit.
|
||||
func (s *Store) CreateSession(setID int64) (*model.Session, error) {
|
||||
// Set prüfen
|
||||
var setName string
|
||||
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND deleted_at IS NULL`, setID).Scan(&setName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Set %d existiert nicht", setID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`INSERT INTO sessions (set_id) VALUES (?)`, setID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// GetSession gibt eine Session mit allen Logs zurück.
|
||||
func (s *Store) GetSession(id int64) (*model.Session, error) {
|
||||
var sess model.Session
|
||||
err := s.db.QueryRow(`
|
||||
SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note
|
||||
FROM sessions s
|
||||
JOIN training_sets ts ON ts.id = s.set_id
|
||||
WHERE s.id = ?`, id,
|
||||
).Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session abfragen: %w", err)
|
||||
}
|
||||
|
||||
logs, err := s.getSessionLogs(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.Logs = logs
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// EndSession beendet eine Session.
|
||||
func (s *Store) EndSession(id int64, note string) (*model.Session, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, note = ?
|
||||
WHERE id = ? AND ended_at IS NULL`, note, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session beenden: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// ListSessions gibt paginierte Sessions zurück (neueste zuerst).
|
||||
func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note
|
||||
FROM sessions s
|
||||
JOIN training_sets ts ON ts.id = s.set_id
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT ? OFFSET ?`, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sessions abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []model.Session
|
||||
for rows.Next() {
|
||||
var sess model.Session
|
||||
if err := rows.Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note); err != nil {
|
||||
return nil, fmt.Errorf("Session scannen: %w", err)
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []model.Session{}
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLog fügt einen Satz zu einer offenen Session hinzu.
|
||||
func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) {
|
||||
// Session offen?
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Übungsname denormalisiert speichern
|
||||
var exerciseName string
|
||||
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", req.ExerciseID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung abfragen: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO session_logs (session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionID, req.ExerciseID, exerciseName, req.SetNumber, req.WeightKg, req.Reps, req.Note,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
return nil, fmt.Errorf("UNIQUE_VIOLATION: Satz %d für diese Übung existiert bereits", req.SetNumber)
|
||||
}
|
||||
return nil, fmt.Errorf("Log erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.getLog(id)
|
||||
}
|
||||
|
||||
// UpdateLog korrigiert einen Satz in einer offenen Session.
|
||||
func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (*model.SessionLog, error) {
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log gehört zur Session?
|
||||
var exists bool
|
||||
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM session_logs WHERE id = ? AND session_id = ?)`, logID, sessionID).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Partielle Updates
|
||||
updates := []string{}
|
||||
args := []any{}
|
||||
if req.WeightKg != nil {
|
||||
updates = append(updates, "weight_kg = ?")
|
||||
args = append(args, *req.WeightKg)
|
||||
}
|
||||
if req.Reps != nil {
|
||||
updates = append(updates, "reps = ?")
|
||||
args = append(args, *req.Reps)
|
||||
}
|
||||
if req.Note != nil {
|
||||
updates = append(updates, "note = ?")
|
||||
args = append(args, *req.Note)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return s.getLog(logID)
|
||||
}
|
||||
|
||||
args = append(args, logID)
|
||||
_, err = s.db.Exec(
|
||||
fmt.Sprintf("UPDATE session_logs SET %s WHERE id = ?", strings.Join(updates, ", ")),
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log aktualisieren: %w", err)
|
||||
}
|
||||
return s.getLog(logID)
|
||||
}
|
||||
|
||||
// DeleteLog löscht einen Satz aus einer offenen Session.
|
||||
func (s *Store) DeleteLog(sessionID, logID int64) error {
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`DELETE FROM session_logs WHERE id = ? AND session_id = ?`, logID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Log löschen: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastLog gibt die letzten Werte einer Übung zurück.
|
||||
func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) {
|
||||
var resp model.LastLogResponse
|
||||
err := s.db.QueryRow(`
|
||||
SELECT weight_kg, reps FROM session_logs
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY logged_at DESC LIMIT 1`, exerciseID,
|
||||
).Scan(&resp.WeightKg, &resp.Reps)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Letzten Log abfragen: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// checkSessionOpen prüft ob eine Session offen ist.
|
||||
func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||
var endedAt *string
|
||||
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("Session %d existiert nicht", sessionID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Session prüfen: %w", err)
|
||||
}
|
||||
if endedAt != nil {
|
||||
return fmt.Errorf("SESSION_CLOSED: Session ist bereits beendet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLog gibt einen einzelnen Log-Eintrag zurück.
|
||||
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||
var log model.SessionLog
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs WHERE id = ?`, id,
|
||||
).Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log abfragen: %w", err)
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
|
||||
// getSessionLogs gibt alle Logs einer Session zurück.
|
||||
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs
|
||||
WHERE session_id = ?
|
||||
ORDER BY exercise_id, set_number`, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Logs abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []model.SessionLog
|
||||
for rows.Next() {
|
||||
var log model.SessionLog
|
||||
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
|
||||
return nil, fmt.Errorf("Log scannen: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.SessionLog{}
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
200
backend/internal/store/set_store.go
Executable file
200
backend/internal/store/set_store.go
Executable file
@@ -0,0 +1,200 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück.
|
||||
func (s *Store) ListSets() ([]model.TrainingSet, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, created_at FROM training_sets
|
||||
WHERE deleted_at IS NULL ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sets abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sets []model.TrainingSet
|
||||
for rows.Next() {
|
||||
var ts model.TrainingSet
|
||||
if err := rows.Scan(&ts.ID, &ts.Name, &ts.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Set scannen: %w", err)
|
||||
}
|
||||
sets = append(sets, ts)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sets == nil {
|
||||
sets = []model.TrainingSet{}
|
||||
}
|
||||
|
||||
for i := range sets {
|
||||
exercises, err := s.getSetExercises(sets[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sets[i].Exercises = exercises
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// GetSet gibt ein einzelnes Set mit Übungen zurück.
|
||||
func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
|
||||
var ts model.TrainingSet
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, name, created_at, deleted_at FROM training_sets WHERE id = ?`, id,
|
||||
).Scan(&ts.ID, &ts.Name, &ts.CreatedAt, &ts.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set abfragen: %w", err)
|
||||
}
|
||||
|
||||
exercises, err := s.getSetExercises(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts.Exercises = exercises
|
||||
return &ts, nil
|
||||
}
|
||||
|
||||
// CreateSet legt ein neues Set an (in einer Transaktion).
|
||||
func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Transaktion starten: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Prüfen ob alle Übungen existieren
|
||||
for _, eid := range req.ExerciseIDs {
|
||||
var exists bool
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
for pos, eid := range req.ExerciseIDs {
|
||||
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("Transaktion committen: %w", err)
|
||||
}
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen).
|
||||
func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Transaktion starten: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Prüfen ob Set existiert
|
||||
var exists bool
|
||||
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND deleted_at IS NULL)`, id).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Prüfen ob alle Übungen existieren
|
||||
for _, eid := range req.ExerciseIDs {
|
||||
var eExists bool
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&eExists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
if !eExists {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE training_sets SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, req.Name, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set aktualisieren: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM set_exercises WHERE set_id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übungen löschen: %w", err)
|
||||
}
|
||||
|
||||
for pos, eid := range req.ExerciseIDs {
|
||||
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("Transaktion committen: %w", err)
|
||||
}
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// SoftDeleteSet markiert ein Set als gelöscht.
|
||||
func (s *Store) SoftDeleteSet(id int64) error {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE training_sets SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Set löschen: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSetExercises lädt die Übungen eines Sets sortiert nach Position.
|
||||
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.created_at, e.updated_at
|
||||
FROM exercises e
|
||||
JOIN set_exercises se ON se.exercise_id = e.id
|
||||
WHERE se.set_id = ?
|
||||
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.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()
|
||||
}
|
||||
85
backend/internal/store/stats_store.go
Executable file
85
backend/internal/store/stats_store.go
Executable file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// StatsOverview enthält die Gesamtübersicht.
|
||||
type StatsOverview struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
SessionsThisWeek int `json:"sessions_this_week"`
|
||||
Exercises []model.ExerciseStats `json:"exercises"`
|
||||
}
|
||||
|
||||
// GetStatsOverview gibt die Gesamtstatistik zurück.
|
||||
func (s *Store) GetStatsOverview() (*StatsOverview, error) {
|
||||
var overview StatsOverview
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL),
|
||||
(SELECT COALESCE(SUM(weight_kg * reps), 0) FROM session_logs),
|
||||
(SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL AND started_at >= date('now', '-7 days'))
|
||||
`).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übersicht abfragen: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
sl.exercise_id,
|
||||
sl.exercise_name,
|
||||
MAX(sl.weight_kg) as max_weight_kg,
|
||||
SUM(sl.weight_kg * sl.reps) as total_volume_kg,
|
||||
COUNT(*) as total_sets,
|
||||
MAX(sl.logged_at) as last_trained
|
||||
FROM session_logs sl
|
||||
GROUP BY sl.exercise_id
|
||||
ORDER BY last_trained DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var es model.ExerciseStats
|
||||
if err := rows.Scan(&es.ExerciseID, &es.ExerciseName, &es.MaxWeightKg, &es.TotalVolumeKg, &es.TotalSets, &es.LastTrained); err != nil {
|
||||
return nil, fmt.Errorf("Übungs-Stats scannen: %w", err)
|
||||
}
|
||||
overview.Exercises = append(overview.Exercises, es)
|
||||
}
|
||||
if overview.Exercises == nil {
|
||||
overview.Exercises = []model.ExerciseStats{}
|
||||
}
|
||||
return &overview, rows.Err()
|
||||
}
|
||||
|
||||
// GetExerciseHistory gibt die letzten N Logs einer Übung zurück.
|
||||
func (s *Store) GetExerciseHistory(exerciseID int64, limit int) ([]model.SessionLog, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY logged_at DESC
|
||||
LIMIT ?`, exerciseID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungshistorie abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []model.SessionLog
|
||||
for rows.Next() {
|
||||
var log model.SessionLog
|
||||
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
|
||||
return nil, fmt.Errorf("Log scannen: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.SessionLog{}
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
37
backend/internal/store/store.go
Executable file
37
backend/internal/store/store.go
Executable file
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Store kapselt den Datenbankzugriff.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New erstellt einen neuen Store und konfiguriert SQLite.
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datenbank öffnen: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("Datenbank-Verbindung prüfen: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// DB gibt die zugrundeliegende Datenbankverbindung zurück (für Migrations).
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Close schliesst die Datenbankverbindung.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user