Files
krafttrainer/backend/internal/store/session_store.go
Christoph K. 4db170b467 init
2026-04-07 09:49:17 +02:00

358 lines
11 KiB
Go
Executable File

package store
import (
"database/sql"
"fmt"
"krafttrainer/internal/model"
"strings"
)
// 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)
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, user_id) VALUES (?, ?)`, setID, userID)
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 (intern, ohne User-Scope).
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 eines Nutzers.
func (s *Store) EndSession(id, userID int64, note string) (*model.Session, error) {
result, err := s.db.Exec(`
UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, note = ?
WHERE id = ? AND user_id = ? AND ended_at IS NULL`, note, id, userID,
)
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 eines Nutzers zurück (neueste zuerst).
func (s *Store) ListSessions(userID int64, 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
WHERE s.user_id = ?
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?`, userID, 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()
}
// 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 {
return nil, err
}
// exercise_name wird denormalisiert gespeichert, damit historische Logs
// erhalten bleiben wenn die Übung später gelöscht wird.
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
}
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
}
// Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update).
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 für einen Nutzer zurück.
func (s *Store) GetLastLog(exerciseID, userID int64) (*model.LastLogResponse, error) {
var resp model.LastLogResponse
err := s.db.QueryRow(`
SELECT sl.weight_kg, sl.reps
FROM session_logs sl
JOIN sessions s ON s.id = sl.session_id
WHERE sl.exercise_id = ? AND s.user_id = ?
ORDER BY sl.logged_at DESC LIMIT 1`, exerciseID, userID,
).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
}
// DeleteSession löscht eine abgeschlossene Session und alle zugehörigen Logs.
// Gibt einen Fehler mit "SESSION_NOT_FOUND" zurück wenn die Session nicht existiert
// oder nicht zum angegebenen Nutzer gehört. Gibt einen Fehler mit "SESSION_OPEN"
// zurück wenn die Session noch nicht beendet wurde.
func (s *Store) DeleteSession(id, userID int64) error {
var endedAt *string
err := s.db.QueryRow(
`SELECT ended_at FROM sessions WHERE id = ? AND user_id = ?`, id, userID,
).Scan(&endedAt)
if err == sql.ErrNoRows {
return fmt.Errorf("SESSION_NOT_FOUND: Session %d nicht gefunden", id)
}
if err != nil {
return fmt.Errorf("Session prüfen: %w", err)
}
if endedAt == nil {
return fmt.Errorf("SESSION_OPEN: Session ist noch nicht beendet")
}
// Logs zuerst löschen (kein ON DELETE CASCADE garantiert), dann Session
_, err = s.db.Exec(`DELETE FROM session_logs WHERE session_id = ?`, id)
if err != nil {
return fmt.Errorf("Session-Logs löschen: %w", err)
}
_, err = s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Session löschen: %w", err)
}
return nil
}
// checkSessionOpen prüft ob eine Session existiert und noch nicht beendet wurde.
// Gibt einen Fehler mit "SESSION_CLOSED" zurück wenn ended_at bereits gesetzt 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 lädt einen einzelnen Session-Log-Eintrag anhand seiner ID.
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 lädt alle Logs einer Session, sortiert nach Übung und Satznummer.
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()
}