From e1f274b744177a746c1a2b301892a4fe1b1a991f Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 4 Sep 2026 12:29:44 +0200 Subject: [PATCH] feat: added exercises handler/service/repo --- internal/api/handler/exercise.go | 63 ++++++++++++ internal/api/request/paginated.go | 58 +++++++++++ internal/api/router/router.go | 5 + internal/domain/exercise.go | 41 ++++++++ internal/domain/exercise_equipment.go | 134 ++++++++++++++++++++++++++ internal/domain/exercise_load.go | 95 ++++++++++++++++++ internal/domain/exercise_metric.go | 95 ++++++++++++++++++ internal/repository/exercise.go | 64 ++++++++++++ internal/repository/store.go | 10 +- 9 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 internal/api/handler/exercise.go create mode 100644 internal/api/request/paginated.go create mode 100644 internal/domain/exercise.go create mode 100644 internal/domain/exercise_equipment.go create mode 100644 internal/domain/exercise_load.go create mode 100644 internal/domain/exercise_metric.go create mode 100644 internal/repository/exercise.go diff --git a/internal/api/handler/exercise.go b/internal/api/handler/exercise.go new file mode 100644 index 0000000..31209ca --- /dev/null +++ b/internal/api/handler/exercise.go @@ -0,0 +1,63 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/robindittmar/dttmr-api/internal/api/request" + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type ExerciseHandler struct { + ExerciseService *domain.ExerciseService +} + +func NewExerciseHandler(exerciseService *domain.ExerciseService) *ExerciseHandler { + return &ExerciseHandler{ExerciseService: exerciseService} +} + +// GetExercises handles fetching the list of exercises +// +// @Summary Get exercises route +// @Description Gets a list of all exercises +// @Tags Exercise +// @Accept json +// @Produce json +// @Param page query int false "page" +// @Param count query int false "count" +// @Success 200 {object} response.Paginated[domain.Exercise] +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 400 {object} response.ErrorResponse "invalid value for page" +// @Error 400 {object} response.ErrorResponse "invalid value for count" +// @Error 500 {object} response.ErrorResponse "failed to get exercises" +// @Router /exercises [get] +func (h *ExerciseHandler) GetExercises(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + page, count, err := request.ParsePaginatedQueryParams(r) + if err != nil { + response.Error(ctx, w, http.StatusBadRequest, err.Error()) + return + } + + exercises, err := h.ExerciseService.GetExercises(ctx, page, count) + if err != nil { + slog.ErrorContext(ctx, "failed to get exercises", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises") + return + } + + total, err := h.ExerciseService.CountExercises(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to count exercises", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises") + return + } + + response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Exercise]{ + Count: len(exercises), + Total: total, + Data: exercises, + }) +} diff --git a/internal/api/request/paginated.go b/internal/api/request/paginated.go new file mode 100644 index 0000000..b85fffa --- /dev/null +++ b/internal/api/request/paginated.go @@ -0,0 +1,58 @@ +package request + +import ( + "errors" + "log/slog" + "net/http" + "strconv" +) + +var ( + ErrFailedToDecodeRequestQuery = errors.New("failed to decode request query") + ErrInvalidPageValue = errors.New("invalid value for page") + ErrInvalidCountValue = errors.New("invalid value for count") +) + +func ParsePaginatedQueryParams(r *http.Request) (int, int, error) { + ctx := r.Context() + + pageStr := r.URL.Query().Get("page") + if pageStr == "" { + pageStr = "1" + } + countStr := r.URL.Query().Get("count") + if countStr == "" { + countStr = "10" + } + + page, err := strconv.Atoi(pageStr) + if err != nil { + slog.ErrorContext(ctx, + "failed to read page from query", + slog.String("page", pageStr)) + return 0, 0, ErrFailedToDecodeRequestQuery + } + + count, err := strconv.Atoi(countStr) + if err != nil { + slog.ErrorContext(ctx, + "failed to read count from query", + slog.String("count", countStr)) + return 0, 0, ErrFailedToDecodeRequestQuery + } + + if page < 1 { + slog.ErrorContext(ctx, + "page parameter is invalid", + slog.Int("page", page)) + return 0, 0, ErrInvalidPageValue + } + if count <= 0 { + slog.ErrorContext(ctx, + "count parameter is invalid", + slog.Int("count", count)) + return 0, 0, ErrInvalidCountValue + } + + return page, count, nil +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2007727..a644aed 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -26,11 +26,13 @@ func NewMux(cfg Config) http.Handler { userService := domain.NewUserService(store.User) registrationService := domain.NewRegistrationService(store, userService, inviteService) listService := domain.NewListService(store, store.List) + exerciseService := domain.NewExerciseService(store.Exercise) authHandler := handler.NewAuthHandler(authService) inviteHandler := handler.NewInviteHandler(inviteService) userHandler := handler.NewUserHandler(userService, authService, registrationService) listHandler := handler.NewListHandler(listService, userService) + exerciseHandler := handler.NewExerciseHandler(exerciseService) protected := middleware.WithJWT(authService) @@ -70,6 +72,9 @@ func NewMux(cfg Config) http.Handler { apiMux.Handle("POST /lists/items/{id}/complete", protected(listHandler.SetListItemCompleted)) apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems)) + // Exercises + apiMux.Handle("GET /exercises", protected(exerciseHandler.GetExercises)) + mux := http.NewServeMux() mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux)) diff --git a/internal/domain/exercise.go b/internal/domain/exercise.go new file mode 100644 index 0000000..9c51189 --- /dev/null +++ b/internal/domain/exercise.go @@ -0,0 +1,41 @@ +package domain + +import ( + "context" + "time" +) + +type ExerciseRepository interface { + CreateExercise(ctx context.Context) (*Exercise, error) + DeleteExercise(ctx context.Context, id string) error + GetExercises(ctx context.Context, offset int, count int) ([]Exercise, error) + CountExercises(ctx context.Context) (int, error) +} + +type Exercise struct { + ID string `json:"id"` + Name string `json:"name"` + Equipment []Equipment `json:"equipment"` + Metric Metric `json:"metric"` + Load Load `json:"load"` + Tags []string `json:"tags"` + Notes *string `json:"notes"` + ModifiedAt time.Time `json:"modified_at"` +} + +type ExerciseService struct { + repo ExerciseRepository +} + +func NewExerciseService(r ExerciseRepository) *ExerciseService { + return &ExerciseService{repo: r} +} + +func (s *ExerciseService) GetExercises(ctx context.Context, page int, count int) ([]Exercise, error) { + offset := (page - 1) * count + return s.repo.GetExercises(ctx, offset, count) +} + +func (s *ExerciseService) CountExercises(ctx context.Context) (int, error) { + return s.repo.CountExercises(ctx) +} diff --git a/internal/domain/exercise_equipment.go b/internal/domain/exercise_equipment.go new file mode 100644 index 0000000..a80c91b --- /dev/null +++ b/internal/domain/exercise_equipment.go @@ -0,0 +1,134 @@ +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +type Equipment int + +const ( + EquipmentUnknown Equipment = iota + EquipmentFloor + EquipmentRings + EquipmentPullUpBar + EquipmentParallelBars + EquipmentLowBar + EquipmentParallettes + EquipmentResistanceBand +) + +var equipmentNames = [...]string{ + EquipmentUnknown: "", + EquipmentFloor: "floor", + EquipmentRings: "rings", + EquipmentPullUpBar: "pull_up_bar", + EquipmentParallelBars: "parallel_bars", + EquipmentLowBar: "low_bar", + EquipmentParallettes: "parallettes", + EquipmentResistanceBand: "resistance_band", +} + +var equipmentValues = func() map[string]Equipment { + m := make(map[string]Equipment, len(equipmentNames)) + for i, name := range equipmentNames { + m[name] = Equipment(i) + } + return m +}() + +func (e Equipment) String() string { + if e < 0 || int(e) > len(equipmentNames) { + return "" + } + return equipmentNames[e] +} + +func ParseEquipment(s string) (Equipment, error) { + if e, ok := equipmentValues[s]; ok { + return e, nil + } + return EquipmentUnknown, fmt.Errorf("equipment: unknown value %q", s) +} + +//func (e Equipment) MarshalJSON() ([]byte, error) { +// s := e.String() +// if s == "" { +// return nil, fmt.Errorf("equipment: cannot marshal value %d", int(e)) +// } +// return json.Marshal(s) +//} +// +//func (e *Equipment) UnmarshalJSON(data []byte) error { +// var s string +// if err := json.Unmarshal(data, &s); err != nil { +// return err +// } +// +// v, err := ParseEquipment(s) +// if err != nil { +// return err +// } +// +// *e = v +// return nil +//} + +type EquipmentSet []Equipment + +func (s *EquipmentSet) Scan(src any) error { + if src == nil { + *s = nil + return nil + } + + var raw string + switch v := src.(type) { + case string: + raw = v + case []byte: + raw = string(v) + default: + return fmt.Errorf("equipment: cannot scan %T", src) + } + + names, err := parseTextArray(raw) + if err != nil { + return err + } + out := make(EquipmentSet, len(names)) + for i, name := range names { + e, err := ParseEquipment(name) + if err != nil { + return err + } + out[i] = e + } + *s = out + return nil +} + +func (s EquipmentSet) Value() (driver.Value, error) { + names := make([]string, len(s)) + for i, e := range s { + name := e.String() + if name == "" { + return nil, fmt.Errorf("equipment: cannot store value %d", int(e)) + } + names[i] = name + } + + return "{" + strings.Join(names, ",") + "}", nil +} + +func parseTextArray(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if len(raw) < 2 || raw[0] != '{' || raw[len(raw)-1] != '}' { + return nil, fmt.Errorf("equipment: malformed array %q", raw) + } + if inner := raw[1 : len(raw)-1]; inner != "" { + return strings.Split(inner, ","), nil + } + return nil, nil +} diff --git a/internal/domain/exercise_load.go b/internal/domain/exercise_load.go new file mode 100644 index 0000000..ac6803b --- /dev/null +++ b/internal/domain/exercise_load.go @@ -0,0 +1,95 @@ +package domain + +import ( + "database/sql/driver" + "fmt" +) + +type Load int + +const ( + LoadUnknown Load = iota + LoadBodyweight + LoadExternal +) + +var loadNames = [...]string{ + LoadUnknown: "", + LoadBodyweight: "bodyweight", + LoadExternal: "external", +} + +var loadValues = func() map[string]Load { + m := make(map[string]Load, len(loadNames)) + for i, name := range loadNames { + m[name] = Load(i) + } + return m +}() + +func (l Load) String() string { + if l < 0 || int(l) > len(loadNames) { + return "" + } + return loadNames[l] +} + +func ParseLoad(s string) (Load, error) { + if l, ok := loadValues[s]; ok { + return l, nil + } + return LoadUnknown, fmt.Errorf("load: unknown value %q", s) +} + +//func (l Load) MarshalJSON() ([]byte, error) { +// s := l.String() +// if s == "" { +// return nil, fmt.Errorf("load: cannot marshal value %d", int(l)) +// } +// return json.Marshal(s) +//} +// +//func (l *Load) UnmarshalJSON(data []byte) error { +// var s string +// if err := json.Unmarshal(data, &s); err != nil { +// return err +// } +// +// v, err := ParseLoad(s) +// if err != nil { +// return err +// } +// +// *l = v +// return nil +//} + +func (l *Load) Scan(src any) error { + var s string + switch v := src.(type) { + case nil: + return fmt.Errorf("load: unexpected NULL") + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("load: cannot scan %T", src) + } + + parsed, err := ParseLoad(s) + if err != nil { + return err + } + + *l = parsed + return nil +} + +func (l Load) Value() (driver.Value, error) { + s := l.String() + if s == "" { + return nil, fmt.Errorf("load: cannot store value %d", int(l)) + } + return s, nil +} diff --git a/internal/domain/exercise_metric.go b/internal/domain/exercise_metric.go new file mode 100644 index 0000000..65777db --- /dev/null +++ b/internal/domain/exercise_metric.go @@ -0,0 +1,95 @@ +package domain + +import ( + "database/sql/driver" + "fmt" +) + +type Metric int + +const ( + MetricUnknown Metric = iota + MetricReps + MetricSeconds +) + +var metricNames = [...]string{ + MetricUnknown: "", + MetricReps: "reps", + MetricSeconds: "seconds", +} + +var metricValues = func() map[string]Metric { + m := make(map[string]Metric, len(metricNames)) + for i, name := range metricNames { + m[name] = Metric(i) + } + return m +}() + +func (m Metric) String() string { + if m < 0 || int(m) > len(metricNames) { + return "" + } + return metricNames[m] +} + +func ParseMetric(s string) (Metric, error) { + if m, ok := metricValues[s]; ok { + return m, nil + } + return MetricUnknown, fmt.Errorf("metric: unknown value %q", s) +} + +//func (m Metric) MarshalJSON() ([]byte, error) { +// s := m.String() +// if s == "" { +// return nil, fmt.Errorf("metric: cannot marshal value %d", int(m)) +// } +// return json.Marshal(s) +//} +// +//func (m *Metric) UnmarshalJSON(data []byte) error { +// var s string +// if err := json.Unmarshal(data, &s); err != nil { +// return err +// } +// +// v, err := ParseMetric(s) +// if err != nil { +// return err +// } +// +// *m = v +// return nil +//} + +func (m *Metric) Scan(src any) error { + var s string + switch v := src.(type) { + case nil: + return fmt.Errorf("metric: unexpected NULL") + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("metric: cannot scan %T", src) + } + + parsed, err := ParseMetric(s) + if err != nil { + return err + } + + *m = parsed + return nil +} + +func (m Metric) Value() (driver.Value, error) { + s := m.String() + if s == "" { + return nil, fmt.Errorf("metric: cannot store value %d", int(m)) + } + return s, nil +} diff --git a/internal/repository/exercise.go b/internal/repository/exercise.go new file mode 100644 index 0000000..75f2ea7 --- /dev/null +++ b/internal/repository/exercise.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +var m = pgtype.NewMap() + +type ExerciseRepo struct { + Repo +} + +func (r *ExerciseRepo) CreateExercise(ctx context.Context) (*domain.Exercise, error) { + return nil, nil +} + +func (r *ExerciseRepo) DeleteExercise(ctx context.Context, id string) error { + return nil +} + +func (r *ExerciseRepo) GetExercises(ctx context.Context, offset int, count int) ([]domain.Exercise, error) { + rows, err := r.conn(ctx).QueryContext(ctx, + "SELECT id, name, equipment, metric, load, tags, notes, modified_at FROM exercises WHERE user_id IS NULL ORDER BY modified_at DESC OFFSET $1 LIMIT $2", + offset, count, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get exercises: %w", err) + } + defer rows.Close() + + exercises := make([]domain.Exercise, 0, count) + for rows.Next() { + var e domain.Exercise + err = rows.Scan(&e.ID, &e.Name, (*domain.EquipmentSet)(&e.Equipment), &e.Metric, &e.Load, m.SQLScanner(&e.Tags), &e.Notes, &e.ModifiedAt) + if err != nil { + return nil, err + } + + exercises = append(exercises, e) + } + + return exercises, nil +} + +func (r *ExerciseRepo) CountExercises(ctx context.Context) (int, error) { + var count int + err := r.conn(ctx).QueryRowContext(ctx, + "SELECT COUNT(*) FROM exercises", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count exercises: %w", err) + } + + return count, nil +} diff --git a/internal/repository/store.go b/internal/repository/store.go index 27bade3..2944306 100644 --- a/internal/repository/store.go +++ b/internal/repository/store.go @@ -4,10 +4,11 @@ import "database/sql" type Store struct { *Transactor - Auth *AuthRepo - Invite *InviteRepo - List *ListRepo - User *UserRepo + Auth *AuthRepo + Invite *InviteRepo + List *ListRepo + User *UserRepo + Exercise *ExerciseRepo } func NewStore(db *sql.DB) *Store { @@ -19,5 +20,6 @@ func NewStore(db *sql.DB) *Store { Invite: &InviteRepo{r}, List: &ListRepo{r}, User: &UserRepo{r}, + Exercise: &ExerciseRepo{r}, } }