diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..0ed1c31 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + + "training-tracker/internal/api" + "training-tracker/internal/storage" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + ctx := context.Background() + + db, err := storage.NewDB(ctx) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer db.Close() + + if err := db.Migrate(ctx); err != nil { + slog.Error("failed to run migrations", "error", err) + os.Exit(1) + } + slog.Info("database migrations completed") + + router := api.NewRouter(db) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + slog.Info("server starting", "port", port) + if err := http.ListenAndServe(":"+port, router); err != nil { + slog.Error("server failed", "error", err) + os.Exit(1) + } +} diff --git a/backend/internal/api/exercises.go b/backend/internal/api/exercises.go new file mode 100644 index 0000000..fa81ceb --- /dev/null +++ b/backend/internal/api/exercises.go @@ -0,0 +1,135 @@ +package api + +import ( + "database/sql" + "net/http" + + "training-tracker/internal/models" +) + +type ExerciseHandler struct { + repo ExerciseRepository +} + +func NewExerciseHandler(repo ExerciseRepository) *ExerciseHandler { + return &ExerciseHandler{repo: repo} +} + +func (h *ExerciseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + id, _ := parseID(r, "/api/exercises/") + + switch r.Method { + case http.MethodGet: + if id > 0 { + h.getByID(w, r, id) + } else { + h.list(w, r) + } + case http.MethodPost: + h.create(w, r) + case http.MethodPut: + if id > 0 { + h.update(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "exercise ID required") + } + case http.MethodDelete: + if id > 0 { + h.delete(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "exercise ID required") + } + default: + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (h *ExerciseHandler) list(w http.ResponseWriter, r *http.Request) { + exercises, err := h.repo.List(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list exercises") + return + } + if exercises == nil { + exercises = []models.Exercise{} + } + respondJSON(w, http.StatusOK, exercises) +} + +func (h *ExerciseHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) { + exercise, err := h.repo.GetByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get exercise") + return + } + if exercise == nil { + respondError(w, http.StatusNotFound, "exercise not found") + return + } + respondJSON(w, http.StatusOK, exercise) +} + +func (h *ExerciseHandler) create(w http.ResponseWriter, r *http.Request) { + var req models.CreateExerciseRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.Type != models.ExerciseTypeStrength && req.Type != models.ExerciseTypeCardio { + respondError(w, http.StatusBadRequest, "type must be 'strength' or 'cardio'") + return + } + + exercise, err := h.repo.Create(r.Context(), &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create exercise") + return + } + respondJSON(w, http.StatusCreated, exercise) +} + +func (h *ExerciseHandler) update(w http.ResponseWriter, r *http.Request, id int64) { + var req models.CreateExerciseRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.Type != models.ExerciseTypeStrength && req.Type != models.ExerciseTypeCardio { + respondError(w, http.StatusBadRequest, "type must be 'strength' or 'cardio'") + return + } + + exercise, err := h.repo.Update(r.Context(), id, &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update exercise") + return + } + if exercise == nil { + respondError(w, http.StatusNotFound, "exercise not found") + return + } + respondJSON(w, http.StatusOK, exercise) +} + +func (h *ExerciseHandler) delete(w http.ResponseWriter, r *http.Request, id int64) { + err := h.repo.Delete(r.Context(), id) + if err == sql.ErrNoRows { + respondError(w, http.StatusNotFound, "exercise not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete exercise") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..b3fc3cf --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,36 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) + +// Response helpers + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data != nil { + json.NewEncoder(w).Encode(data) + } +} + +func respondError(w http.ResponseWriter, status int, message string) { + respondJSON(w, status, map[string]string{"error": message}) +} + +func parseID(r *http.Request, prefix string) (int64, error) { + path := strings.TrimPrefix(r.URL.Path, prefix) + path = strings.TrimSuffix(path, "/") + parts := strings.Split(path, "/") + if len(parts) == 0 { + return 0, nil + } + return strconv.ParseInt(parts[0], 10, 64) +} + +func decodeJSON(r *http.Request, v interface{}) error { + return json.NewDecoder(r.Body).Decode(v) +} diff --git a/backend/internal/api/interfaces.go b/backend/internal/api/interfaces.go new file mode 100644 index 0000000..2839890 --- /dev/null +++ b/backend/internal/api/interfaces.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + + "training-tracker/internal/models" +) + +// ExerciseRepository defines the interface for exercise storage operations +type ExerciseRepository interface { + List(ctx context.Context) ([]models.Exercise, error) + GetByID(ctx context.Context, id int64) (*models.Exercise, error) + Create(ctx context.Context, req *models.CreateExerciseRequest) (*models.Exercise, error) + Update(ctx context.Context, id int64, req *models.CreateExerciseRequest) (*models.Exercise, error) + Delete(ctx context.Context, id int64) error +} + +// PlanRepository defines the interface for training plan storage operations +type PlanRepository interface { + List(ctx context.Context) ([]models.TrainingPlan, error) + GetByID(ctx context.Context, id int64) (*models.TrainingPlan, error) + Create(ctx context.Context, req *models.CreatePlanRequest) (*models.TrainingPlan, error) + Update(ctx context.Context, id int64, req *models.CreatePlanRequest) (*models.TrainingPlan, error) + Delete(ctx context.Context, id int64) error +} + +// SessionRepository defines the interface for session storage operations +type SessionRepository interface { + List(ctx context.Context) ([]models.Session, error) + GetByID(ctx context.Context, id int64) (*models.Session, error) + Create(ctx context.Context, req *models.CreateSessionRequest) (*models.Session, error) + Update(ctx context.Context, id int64, req *models.CreateSessionRequest) (*models.Session, error) + Delete(ctx context.Context, id int64) error + AddEntry(ctx context.Context, sessionID int64, req *models.CreateSessionEntryRequest) (*models.SessionEntry, error) +} diff --git a/backend/internal/api/plans.go b/backend/internal/api/plans.go new file mode 100644 index 0000000..e47d368 --- /dev/null +++ b/backend/internal/api/plans.go @@ -0,0 +1,127 @@ +package api + +import ( + "database/sql" + "net/http" + + "training-tracker/internal/models" +) + +type PlanHandler struct { + repo PlanRepository +} + +func NewPlanHandler(repo PlanRepository) *PlanHandler { + return &PlanHandler{repo: repo} +} + +func (h *PlanHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + id, _ := parseID(r, "/api/plans/") + + switch r.Method { + case http.MethodGet: + if id > 0 { + h.getByID(w, r, id) + } else { + h.list(w, r) + } + case http.MethodPost: + h.create(w, r) + case http.MethodPut: + if id > 0 { + h.update(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "plan ID required") + } + case http.MethodDelete: + if id > 0 { + h.delete(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "plan ID required") + } + default: + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (h *PlanHandler) list(w http.ResponseWriter, r *http.Request) { + plans, err := h.repo.List(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list plans") + return + } + if plans == nil { + plans = []models.TrainingPlan{} + } + respondJSON(w, http.StatusOK, plans) +} + +func (h *PlanHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) { + plan, err := h.repo.GetByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get plan") + return + } + if plan == nil { + respondError(w, http.StatusNotFound, "plan not found") + return + } + respondJSON(w, http.StatusOK, plan) +} + +func (h *PlanHandler) create(w http.ResponseWriter, r *http.Request) { + var req models.CreatePlanRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + + plan, err := h.repo.Create(r.Context(), &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create plan") + return + } + respondJSON(w, http.StatusCreated, plan) +} + +func (h *PlanHandler) update(w http.ResponseWriter, r *http.Request, id int64) { + var req models.CreatePlanRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + + plan, err := h.repo.Update(r.Context(), id, &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update plan") + return + } + if plan == nil { + respondError(w, http.StatusNotFound, "plan not found") + return + } + respondJSON(w, http.StatusOK, plan) +} + +func (h *PlanHandler) delete(w http.ResponseWriter, r *http.Request, id int64) { + err := h.repo.Delete(r.Context(), id) + if err == sql.ErrNoRows { + respondError(w, http.StatusNotFound, "plan not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete plan") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..f44425f --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" + "strings" + "training-tracker/internal/storage" +) + +// Router sets up and returns the HTTP router +type Router struct { + exerciseHandler *ExerciseHandler + planHandler *PlanHandler + sessionHandler *SessionHandler +} + +// NewRouter creates a new router with all handlers +func NewRouter(db *storage.DB) *Router { + exerciseRepo := storage.NewExerciseRepository(db) + planRepo := storage.NewPlanRepository(db) + sessionRepo := storage.NewSessionRepository(db) + + return &Router{ + exerciseHandler: NewExerciseHandler(exerciseRepo), + planHandler: NewPlanHandler(planRepo), + sessionHandler: NewSessionHandler(sessionRepo), + } +} + +// ServeHTTP implements http.Handler +func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Enable CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + path := r.URL.Path + + switch { + case strings.HasPrefix(path, "/api/exercises"): + router.exerciseHandler.ServeHTTP(w, r) + case strings.HasPrefix(path, "/api/plans"): + router.planHandler.ServeHTTP(w, r) + case strings.HasPrefix(path, "/api/sessions"): + router.sessionHandler.ServeHTTP(w, r) + case path == "/api/health": + respondJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + respondError(w, http.StatusNotFound, "not found") + } +} diff --git a/backend/internal/api/sessions.go b/backend/internal/api/sessions.go new file mode 100644 index 0000000..4d7eb95 --- /dev/null +++ b/backend/internal/api/sessions.go @@ -0,0 +1,166 @@ +package api + +import ( + "database/sql" + "net/http" + "strings" + + "training-tracker/internal/models" +) + +type SessionHandler struct { + repo SessionRepository +} + +func NewSessionHandler(repo SessionRepository) *SessionHandler { + return &SessionHandler{repo: repo} +} + +func (h *SessionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/sessions") + path = strings.TrimPrefix(path, "/") + parts := strings.Split(path, "/") + + if len(parts) >= 2 && parts[1] == "entries" { + id, err := parseID(r, "/api/sessions/") + if err != nil || id == 0 { + respondError(w, http.StatusBadRequest, "session ID required") + return + } + if r.Method == http.MethodPost { + h.addEntry(w, r, id) + } else { + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + } + return + } + + id, _ := parseID(r, "/api/sessions/") + + switch r.Method { + case http.MethodGet: + if id > 0 { + h.getByID(w, r, id) + } else { + h.list(w, r) + } + case http.MethodPost: + h.create(w, r) + case http.MethodPut: + if id > 0 { + h.update(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "session ID required") + } + case http.MethodDelete: + if id > 0 { + h.delete(w, r, id) + } else { + respondError(w, http.StatusBadRequest, "session ID required") + } + default: + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (h *SessionHandler) list(w http.ResponseWriter, r *http.Request) { + sessions, err := h.repo.List(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list sessions") + return + } + if sessions == nil { + sessions = []models.Session{} + } + respondJSON(w, http.StatusOK, sessions) +} + +func (h *SessionHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) { + session, err := h.repo.GetByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get session") + return + } + if session == nil { + respondError(w, http.StatusNotFound, "session not found") + return + } + respondJSON(w, http.StatusOK, session) +} + +func (h *SessionHandler) create(w http.ResponseWriter, r *http.Request) { + var req models.CreateSessionRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Date.IsZero() { + respondError(w, http.StatusBadRequest, "date is required") + return + } + + session, err := h.repo.Create(r.Context(), &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create session") + return + } + respondJSON(w, http.StatusCreated, session) +} + +func (h *SessionHandler) update(w http.ResponseWriter, r *http.Request, id int64) { + var req models.CreateSessionRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Date.IsZero() { + respondError(w, http.StatusBadRequest, "date is required") + return + } + + session, err := h.repo.Update(r.Context(), id, &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update session") + return + } + if session == nil { + respondError(w, http.StatusNotFound, "session not found") + return + } + respondJSON(w, http.StatusOK, session) +} + +func (h *SessionHandler) delete(w http.ResponseWriter, r *http.Request, id int64) { + err := h.repo.Delete(r.Context(), id) + if err == sql.ErrNoRows { + respondError(w, http.StatusNotFound, "session not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete session") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *SessionHandler) addEntry(w http.ResponseWriter, r *http.Request, sessionID int64) { + var req models.CreateSessionEntryRequest + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.ExerciseID == 0 { + respondError(w, http.StatusBadRequest, "exercise_id is required") + return + } + + entry, err := h.repo.AddEntry(r.Context(), sessionID, &req) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to add session entry") + return + } + respondJSON(w, http.StatusCreated, entry) +}