Add multi-user support with export feature

- 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>
This commit is contained in:
Christoph K.
2026-03-21 23:55:51 +01:00
parent bff85908c3
commit a954f2c59d
24 changed files with 793 additions and 95 deletions

View File

@@ -6,11 +6,11 @@ import (
"krafttrainer/internal/model"
)
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück.
func (s *Store) ListSets() ([]model.TrainingSet, error) {
// ListSets gibt alle nicht-gelöschten Sets eines Nutzers mit ihren Übungen zurück.
func (s *Store) ListSets(userID int64) ([]model.TrainingSet, error) {
rows, err := s.db.Query(`
SELECT id, name, created_at FROM training_sets
WHERE deleted_at IS NULL ORDER BY name`)
WHERE deleted_at IS NULL AND user_id = ? ORDER BY name`, userID)
if err != nil {
return nil, fmt.Errorf("Sets abfragen: %w", err)
}
@@ -41,7 +41,7 @@ func (s *Store) ListSets() ([]model.TrainingSet, error) {
return sets, nil
}
// GetSet gibt ein einzelnes Set mit Übungen zurück.
// GetSet gibt ein einzelnes Set mit Übungen zurück (intern, ohne User-Scope).
func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
var ts model.TrainingSet
err := s.db.QueryRow(`
@@ -63,17 +63,16 @@ func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
}
// CreateSet legt ein neues Set an (in einer Transaktion).
func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, error) {
func (s *Store) CreateSet(userID int64, 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)
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&exists)
if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err)
}
@@ -82,7 +81,7 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro
}
}
result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name)
result, err := tx.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, req.Name, userID)
if err != nil {
return nil, fmt.Errorf("Set erstellen: %w", err)
}
@@ -102,17 +101,16 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro
return s.GetSet(id)
}
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen).
func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) {
// UpdateSet aktualisiert ein Set eines Nutzers (Name + Übungszuordnungen).
func (s *Store) UpdateSet(id, userID 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)
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, id, userID).Scan(&exists)
if err != nil {
return nil, fmt.Errorf("Set prüfen: %w", err)
}
@@ -120,10 +118,9 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin
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)
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&eExists)
if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err)
}
@@ -155,11 +152,11 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin
return s.GetSet(id)
}
// SoftDeleteSet markiert ein Set als gelöscht.
func (s *Store) SoftDeleteSet(id int64) error {
// SoftDeleteSet markiert ein Set eines Nutzers als gelöscht.
func (s *Store) SoftDeleteSet(id, userID 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,
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, id, userID,
)
if err != nil {
return fmt.Errorf("Set löschen: %w", err)