Added exercises; list improvements #38

Merged
robin merged 6 commits from dev into main 2026-09-04 12:30:59 +02:00
18 changed files with 676 additions and 10 deletions
+1 -1
View File
@@ -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 (
+2
View File
@@ -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=
+63
View File
@@ -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,
})
}
+53 -3
View File
@@ -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)
}
+4
View File
@@ -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"`
}
+58
View File
@@ -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
}
+7 -1
View File
@@ -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))
+4 -1
View File
@@ -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
}
@@ -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;
@@ -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;
+41
View File
@@ -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)
}
+134
View File
@@ -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
}
+95
View File
@@ -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
}
+95
View File
@@ -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
}
+16
View File
@@ -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
+66
View File
@@ -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
}
+11
View File
@@ -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,
+2
View File
@@ -8,6 +8,7 @@ type Store struct {
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},
}
}