Add habit HTTP endpoints
- Create DTOs for HTTP requests and responses
- Implement habit handlers (create, get today's, mark)
- Register routes in router: POST /habits, GET /habits/today, POST /habits/{id}/mark
- Add missing repository methods (FindByID, FindByHabitID, FindPendingByHabitID, Delete)
- Wire up dependencies in main.go
- Tested with curl: create, list, mark habits work correctly
This commit is contained in:
+12
-1
@@ -5,6 +5,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"habit-tracker-api/internal/application/commands"
|
||||||
|
"habit-tracker-api/internal/application/queries"
|
||||||
"habit-tracker-api/internal/infrastructure/config"
|
"habit-tracker-api/internal/infrastructure/config"
|
||||||
httpInfra "habit-tracker-api/internal/infrastructure/http"
|
httpInfra "habit-tracker-api/internal/infrastructure/http"
|
||||||
"habit-tracker-api/internal/infrastructure/persistence/sqlite"
|
"habit-tracker-api/internal/infrastructure/persistence/sqlite"
|
||||||
@@ -22,7 +24,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
router := httpInfra.NewRouter(cfg.CORSOrigins)
|
habitRepo := sqlite.NewHabitRepository(db.Conn())
|
||||||
|
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
|
||||||
|
|
||||||
|
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||||
|
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||||
|
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||||
|
|
||||||
|
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, markHandler)
|
||||||
|
|
||||||
|
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||||
log.Printf("Server starting on %s", addr)
|
log.Printf("Server starting on %s", addr)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CreateHabitRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Frequency string `json:"frequency"`
|
||||||
|
SpecificDays []int `json:"specific_days,omitempty"`
|
||||||
|
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||||
|
CarryOver bool `json:"carry_over"`
|
||||||
|
TargetValue *float64 `json:"target_value,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HabitResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Frequency string `json:"frequency"`
|
||||||
|
SpecificDays []int `json:"specific_days,omitempty"`
|
||||||
|
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||||
|
CarryOver bool `json:"carry_over"`
|
||||||
|
TargetValue *float64 `json:"target_value,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkHabitRequest struct {
|
||||||
|
ScheduledDate string `json:"scheduled_date"`
|
||||||
|
Value *float64 `json:"value,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodaysHabitResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TargetValue *float64 `json:"target_value,omitempty"`
|
||||||
|
ScheduledDate time.Time `json:"scheduled_date"`
|
||||||
|
IsCarriedOver bool `json:"is_carried_over"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"habit-tracker-api/internal/application/commands"
|
||||||
|
"habit-tracker-api/internal/application/queries"
|
||||||
|
"habit-tracker-api/internal/shared/errors"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HabitHandlers struct {
|
||||||
|
createHandler *commands.CreateHabitHandler
|
||||||
|
getTodaysHandler *queries.GetTodaysHabitsHandler
|
||||||
|
markHandler *commands.MarkHabitHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHabitHandlers(
|
||||||
|
createHandler *commands.CreateHabitHandler,
|
||||||
|
getTodaysHandler *queries.GetTodaysHabitsHandler,
|
||||||
|
markHandler *commands.MarkHabitHandler,
|
||||||
|
) *HabitHandlers {
|
||||||
|
return &HabitHandlers{
|
||||||
|
createHandler: createHandler,
|
||||||
|
getTodaysHandler: getTodaysHandler,
|
||||||
|
markHandler: markHandler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreateHabitRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-123"
|
||||||
|
|
||||||
|
cmd := commands.CreateHabitCommand{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Type: req.Type,
|
||||||
|
Frequency: req.Frequency,
|
||||||
|
SpecificDays: req.SpecificDays,
|
||||||
|
SpecificDates: req.SpecificDates,
|
||||||
|
CarryOver: req.CarryOver,
|
||||||
|
TargetValue: req.TargetValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
habitID, err := h.createHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == errors.ErrInvalidInput {
|
||||||
|
respondError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to create habit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusCreated, map[string]string{"id": habitID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := "user-123"
|
||||||
|
timezone := "UTC"
|
||||||
|
|
||||||
|
query := queries.GetTodaysHabitsQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Timezone: timezone,
|
||||||
|
Date: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
habits, err := h.getTodaysHandler.Handle(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to get habits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]TodaysHabitResponse, len(habits))
|
||||||
|
for i, habit := range habits {
|
||||||
|
response[i] = TodaysHabitResponse{
|
||||||
|
ID: habit.ID,
|
||||||
|
Name: habit.Name,
|
||||||
|
Type: habit.Type,
|
||||||
|
TargetValue: habit.TargetValue,
|
||||||
|
ScheduledDate: habit.ScheduledDate,
|
||||||
|
IsCarriedOver: habit.IsCarriedOver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
habitID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req MarkHabitRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := commands.MarkHabitCommand{
|
||||||
|
HabitID: habitID,
|
||||||
|
ScheduledDate: scheduledDate,
|
||||||
|
Value: req.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.markHandler.Handle(r.Context(), cmd); err != nil {
|
||||||
|
if err == errors.ErrAlreadyExists {
|
||||||
|
respondError(w, http.StatusConflict, "Habit already marked for this date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == errors.ErrNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "Habit not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to mark habit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{"status": "marked"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondError(w http.ResponseWriter, status int, message string) {
|
||||||
|
respondJSON(w, status, ErrorResponse{Error: message})
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(corsOrigins string) *chi.Mux {
|
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -25,5 +25,11 @@ func NewRouter(corsOrigins string) *chi.Mux {
|
|||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/api/v1/habits", func(r chi.Router) {
|
||||||
|
r.Post("/", habitHandlers.CreateHabit)
|
||||||
|
r.Get("/today", habitHandlers.GetTodaysHabits)
|
||||||
|
r.Post("/{id}/mark", habitHandlers.MarkHabit)
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,3 +134,100 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
|||||||
|
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, habit_id, scheduled_date, completed_at, value, deleted_at
|
||||||
|
FROM habit_entries
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var (
|
||||||
|
entry entities.HabitEntry
|
||||||
|
scheduledDate string
|
||||||
|
deletedAt sql.NullTime
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&entry.ID,
|
||||||
|
&entry.HabitID,
|
||||||
|
&scheduledDate,
|
||||||
|
&entry.CompletedAt,
|
||||||
|
&entry.Value,
|
||||||
|
&deletedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, errors.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
|
||||||
|
if err != nil {
|
||||||
|
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.ScheduledDate = parsedDate
|
||||||
|
|
||||||
|
if deletedAt.Valid {
|
||||||
|
entry.DeletedAt = &deletedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, habit_id, scheduled_date, completed_at, value, deleted_at
|
||||||
|
FROM habit_entries
|
||||||
|
WHERE habit_id = ?
|
||||||
|
ORDER BY scheduled_date DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, habitID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find entries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, habit_id, scheduled_date, completed_at, value, deleted_at
|
||||||
|
FROM habit_entries
|
||||||
|
WHERE habit_id = ?
|
||||||
|
AND scheduled_date < ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY scheduled_date DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, habitID, beforeDate.Format("2006-01-02"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find pending entries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM habit_entries WHERE id = ?`
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -206,3 +206,38 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
|||||||
|
|
||||||
return habits, nil
|
return habits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, name, description, type, frequency,
|
||||||
|
specific_days, specific_dates, carry_over, target_value,
|
||||||
|
created_at, archived_at
|
||||||
|
FROM habits
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return r.scanHabits(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM habits WHERE id = ?`
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete habit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user