- New users table (migration 004) with user_id on exercises, training_sets, sessions
- User CRUD endpoints (GET/POST /api/v1/users, DELETE /api/v1/users/{id})
- All existing endpoints scoped to X-User-ID header
- CSV export endpoint (GET /api/v1/export) for completed sessions
- UserGate in PageShell: blocks app until a user is selected
- Settings page for managing users (create, switch, delete)
- BottomNav/Sidebar updated with settings navigation
- Fix: nil pointer panic in handleDeleteUser on success path
- Fix: export download now uses fetch with X-User-ID header instead of window.location.href
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
268 lines
7.9 KiB
Go
Executable File
268 lines
7.9 KiB
Go
Executable File
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"krafttrainer/internal/model"
|
|
"strings"
|
|
)
|
|
|
|
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
|
|
func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) {
|
|
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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
}
|