Files
geniusrun/backend/internal/store/workoutkinds.go
Christophe Vila e8bde134ae store: scope workout kinds and their pace/HR ranges to a user_id
workout_kinds gains a real user_id column (Task 1); workout_type_paces has
none of its own and is scoped via a join to workout_kinds instead, since
it's always accessed through a specific kind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 13:44:06 +02:00

118 lines
4.2 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
)
// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...).
// RuleJSON holds the condition tree evaluated by internal/classify.
type WorkoutKind struct {
ID int64
Name string
Description string
Color string
RuleJSON string
Priority int
IsActive bool
CreatedAt string
UpdatedAt string
}
func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
var k WorkoutKind
err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt)
return k, err
}
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
// CreateWorkoutKind inserts a new workout kind and returns its id.
func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?,?)`,
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
if err != nil {
return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err)
}
return res.LastInsertId()
}
// UpdateWorkoutKind updates an existing workout kind's editable fields,
// scoped so it can only ever affect a row owned by userID.
func (db *DB) UpdateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) error {
_, err := db.ExecContext(ctx, `
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
WHERE id=? AND user_id=?`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID)
if err != nil {
return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err)
}
return nil
}
// GetWorkoutKind fetches one workout kind by id, scoped to userID.
func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID)
k, err := scanWorkoutKind(row)
if err == sql.ErrNoRows {
return WorkoutKind{}, false, nil
}
if err != nil {
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d for user %d: %w", id, userID, err)
}
return k, true, nil
}
// GetWorkoutKindByName fetches one workout kind by name, scoped to userID
// (the same name can exist for different users -- see UNIQUE(user_id, name)).
func (db *DB) GetWorkoutKindByName(ctx context.Context, userID int64, name string) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ? AND user_id = ?`, name, userID)
k, err := scanWorkoutKind(row)
if err == sql.ErrNoRows {
return WorkoutKind{}, false, nil
}
if err != nil {
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err)
}
return k, true, nil
}
// ListWorkoutKinds returns workout kinds for userID. If activeOnly, soft-deleted
// (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
if activeOnly {
query += ` AND is_active = 1`
}
query += ` ORDER BY priority DESC, name`
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err)
}
defer rows.Close()
kinds := []WorkoutKind{}
for rows.Next() {
k, err := scanWorkoutKind(rows)
if err != nil {
return nil, fmt.Errorf("scan workout kind row: %w", err)
}
kinds = append(kinds, k)
}
return kinds, rows.Err()
}
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
// referencing it) intact. Scoped to userID.
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, userID, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=? AND user_id=?`, id, userID)
if err != nil {
return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err)
}
return nil
}