Merge pull request 'Added exercises; list improvements' (#38) from dev into main
This commit was merged in pull request #38.
This commit is contained in:
@@ -16,7 +16,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.46.0
|
go.opentelemetry.io/otel/sdk v1.46.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||||
go.opentelemetry.io/otel/trace 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 (
|
require (
|
||||||
|
|||||||
@@ -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=
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
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 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -353,6 +353,56 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Status(w, http.StatusNoContent)
|
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
|
// SetListItemCompleted handles updating "is_completed" of a list item
|
||||||
//
|
//
|
||||||
// @Summary Updates "is_completed" of 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 url"
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
// @Error 401 {object} response.ErrorResponse "not authorized"
|
// @Error 401 {object} response.ErrorResponse "not authorized"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to update list item"
|
// @Error 500 {object} response.ErrorResponse "failed to set list item completed"
|
||||||
// @Router /lists/items/{id} [post]
|
// @Router /lists/items/{id}/complete [post]
|
||||||
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -399,7 +449,7 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
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)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type UpdateListItemPayload struct {
|
|||||||
IsCompleted bool `json:"is_completed"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetListItemTitlePayload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
type SetListItemCompletedPayload struct {
|
type SetListItemCompletedPayload struct {
|
||||||
IsCompleted bool `json:"is_completed"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,11 +26,13 @@ func NewMux(cfg Config) http.Handler {
|
|||||||
userService := domain.NewUserService(store.User)
|
userService := domain.NewUserService(store.User)
|
||||||
registrationService := domain.NewRegistrationService(store, userService, inviteService)
|
registrationService := domain.NewRegistrationService(store, userService, inviteService)
|
||||||
listService := domain.NewListService(store, store.List)
|
listService := domain.NewListService(store, store.List)
|
||||||
|
exerciseService := domain.NewExerciseService(store.Exercise)
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
inviteHandler := handler.NewInviteHandler(inviteService)
|
inviteHandler := handler.NewInviteHandler(inviteService)
|
||||||
userHandler := handler.NewUserHandler(userService, authService, registrationService)
|
userHandler := handler.NewUserHandler(userService, authService, registrationService)
|
||||||
listHandler := handler.NewListHandler(listService, userService)
|
listHandler := handler.NewListHandler(listService, userService)
|
||||||
|
exerciseHandler := handler.NewExerciseHandler(exerciseService)
|
||||||
|
|
||||||
protected := middleware.WithJWT(authService)
|
protected := middleware.WithJWT(authService)
|
||||||
|
|
||||||
@@ -66,9 +68,13 @@ func NewMux(cfg Config) http.Handler {
|
|||||||
apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem))
|
apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem))
|
||||||
apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
|
apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
|
||||||
apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
|
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))
|
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
|
||||||
|
|
||||||
|
// Exercises
|
||||||
|
apiMux.Handle("GET /exercises", protected(exerciseHandler.GetExercises))
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ func assignIntFromEnv(key string, target *int) {
|
|||||||
if val, exists := os.LookupEnv(key); exists {
|
if val, exists := os.LookupEnv(key); exists {
|
||||||
parsed, err := strconv.Atoi(val)
|
parsed, err := strconv.Atoi(val)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
*target = parsed
|
*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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ type ListRepository interface {
|
|||||||
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
|
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
|
||||||
DeleteListItem(ctx context.Context, listItemID string) error
|
DeleteListItem(ctx context.Context, listItemID string) error
|
||||||
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) 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
|
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
|
||||||
GetListItems(ctx context.Context, listID string) ([]ListItem, 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)
|
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 {
|
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
|
||||||
if listItemID == "" {
|
if listItemID == "" {
|
||||||
return ErrListItemIDMissing
|
return ErrListItemIDMissing
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -150,6 +150,17 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
|
|||||||
return nil
|
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 {
|
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",
|
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
|
||||||
isCompleted, listItemID,
|
isCompleted, listItemID,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Store struct {
|
|||||||
Invite *InviteRepo
|
Invite *InviteRepo
|
||||||
List *ListRepo
|
List *ListRepo
|
||||||
User *UserRepo
|
User *UserRepo
|
||||||
|
Exercise *ExerciseRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore(db *sql.DB) *Store {
|
func NewStore(db *sql.DB) *Store {
|
||||||
@@ -19,5 +20,6 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
Invite: &InviteRepo{r},
|
Invite: &InviteRepo{r},
|
||||||
List: &ListRepo{r},
|
List: &ListRepo{r},
|
||||||
User: &UserRepo{r},
|
User: &UserRepo{r},
|
||||||
|
Exercise: &ExerciseRepo{r},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user