diff --git a/go.mod b/go.mod index 31ecf67..81e9641 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 ) require ( diff --git a/go.sum b/go.sum index cdab061..3b7223b 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= 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/handler/list.go b/internal/api/handler/list.go index ad2863f..8875bae 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -353,6 +353,56 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { response.Status(w, http.StatusNoContent) } +// SetListItemTitle handles updating "title" of a list item +// +// @Summary Updates "title" of list item +// @Description Update an existing list item, setting the "title" field +// @Tags List +// @Accept json +// @Produce json +// @Param id path int true "List Item ID" +// @Param payload body request.SetListItemTitlePayload true "Update list item title payload" +// @Success 204 {object} nil +// @Error 400 {object} response.ErrorResponse "failed to decode request url" +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 401 {object} response.ErrorResponse "not authorized" +// @Error 500 {object} response.ErrorResponse "failed to set list item title" +// @Router /lists/items/{id}/title [post] +func (h *ListHandler) SetListItemTitle(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + listItemID := r.PathValue("id") + if listItemID == "" { + slog.ErrorContext(ctx, "failed to read list item id from path") + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url") + return + } + + payload, err := request.DecodeJSON[request.SetListItemTitlePayload](r) + if err != nil { + slog.ErrorContext(ctx, "failed to decode set list item title payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body") + return + } + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusUnauthorized, "not authorized") + return + } + + err = h.ListService.SetListItemTitle(ctx, authContext.UserID, listItemID, payload.Title) + if err != nil { + slog.ErrorContext(ctx, "failed to set list item title", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to set list item title") + return + } + + slog.InfoContext(ctx, "update list item title successful", slog.String("list_item_id", listItemID)) + response.Status(w, http.StatusNoContent) +} + // SetListItemCompleted handles updating "is_completed" of a list item // // @Summary Updates "is_completed" of list item @@ -366,8 +416,8 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { // @Error 400 {object} response.ErrorResponse "failed to decode request url" // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 401 {object} response.ErrorResponse "not authorized" -// @Error 500 {object} response.ErrorResponse "failed to update list item" -// @Router /lists/items/{id} [post] +// @Error 500 {object} response.ErrorResponse "failed to set list item completed" +// @Router /lists/items/{id}/complete [post] func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -399,7 +449,7 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques return } - slog.InfoContext(ctx, "updated list item completed successful", slog.String("list_item_id", listItemID)) + slog.InfoContext(ctx, "update list item completed successful", slog.String("list_item_id", listItemID)) response.Status(w, http.StatusNoContent) } diff --git a/internal/api/request/list.go b/internal/api/request/list.go index ca8bf1e..fcb5093 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -25,6 +25,10 @@ type UpdateListItemPayload struct { IsCompleted bool `json:"is_completed"` } +type SetListItemTitlePayload struct { + Title string `json:"title"` +} + type SetListItemCompletedPayload struct { IsCompleted bool `json:"is_completed"` } 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 fb94676..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) @@ -66,9 +68,13 @@ func NewMux(cfg Config) http.Handler { apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem)) apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem)) apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem)) - apiMux.Handle("POST /lists/items/{id}", protected(listHandler.SetListItemCompleted)) + apiMux.Handle("POST /lists/items/{id}/title", protected(listHandler.SetListItemTitle)) + 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/config/config.go b/internal/config/config.go index 5284166..658f0c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,7 +51,10 @@ func assignIntFromEnv(key string, target *int) { if val, exists := os.LookupEnv(key); exists { parsed, err := strconv.Atoi(val) if err != nil { - slog.Error("failed to parse environment variable", slog.String("var", key), slog.Any("error", err)) + slog.Error("failed to parse environment variable", + slog.String("key", key), + slog.String("value", val), + slog.Any("error", err)) } else { *target = parsed } diff --git a/internal/database/migrations/000005_rename_workout_load.down.sql b/internal/database/migrations/000005_rename_workout_load.down.sql new file mode 100644 index 0000000..4f21adb --- /dev/null +++ b/internal/database/migrations/000005_rename_workout_load.down.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE exercises DROP CONSTRAINT exercises_load_check; + +UPDATE exercises SET load = 'absolute' WHERE load = 'external'; + +ALTER TABLE exercises ADD CONSTRAINT exercises_load_check + CHECK (load IN ('bodyweight', 'absolute')); + +COMMIT; diff --git a/internal/database/migrations/000005_rename_workout_load.up.sql b/internal/database/migrations/000005_rename_workout_load.up.sql new file mode 100644 index 0000000..75fbf28 --- /dev/null +++ b/internal/database/migrations/000005_rename_workout_load.up.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE exercises DROP CONSTRAINT exercises_load_check; + +UPDATE exercises SET load = 'external' WHERE load = 'absolute'; + +ALTER TABLE exercises ADD CONSTRAINT exercises_load_check + CHECK (load IN ('bodyweight', 'external')); + +COMMIT; 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/domain/list.go b/internal/domain/list.go index 7c93f32..68bed6d 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -44,6 +44,7 @@ type ListRepository interface { CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) DeleteListItem(ctx context.Context, listItemID string) error UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error + SetListItemTitle(ctx context.Context, listItemID string, title string) error SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error GetListItems(ctx context.Context, listID string) ([]ListItem, error) } @@ -172,6 +173,21 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted) } +func (s *ListService) SetListItemTitle(ctx context.Context, authUserID string, listItemID string, title string) error { + if listItemID == "" { + return ErrListItemIDMissing + } + if title == "" { + return ErrListItemTitleMissing + } + + if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil { + return err + } + + return s.repo.SetListItemTitle(ctx, listItemID, title) +} + func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error { if listItemID == "" { return ErrListItemIDMissing diff --git a/internal/repository/exercise.go b/internal/repository/exercise.go new file mode 100644 index 0000000..c4faf19 --- /dev/null +++ b/internal/repository/exercise.go @@ -0,0 +1,66 @@ +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) { + _ = ctx + return nil, nil +} + +func (r *ExerciseRepo) DeleteExercise(ctx context.Context, id string) error { + _, _ = ctx, id + 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/list.go b/internal/repository/list.go index c8ae0fa..4a8b6cf 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -150,6 +150,17 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title return nil } +func (r *ListRepo) SetListItemTitle(ctx context.Context, listItemID string, title string) error { + _, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, modified_at = NOW() WHERE id = $2", + title, listItemID, + ) + if err != nil { + return fmt.Errorf("failed to update list item title: %w", err) + } + + return nil +} + func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error { _, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2", isCompleted, listItemID, 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}, } }