diff --git a/cmd/api/main.go b/cmd/api/main.go index 1380a7d..5df3e19 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,6 +5,8 @@ import ( "log" "net/http" + "habit-tracker-api/internal/application/commands" + "habit-tracker-api/internal/application/queries" "habit-tracker-api/internal/infrastructure/config" httpInfra "habit-tracker-api/internal/infrastructure/http" "habit-tracker-api/internal/infrastructure/persistence/sqlite" @@ -22,7 +24,16 @@ func main() { } 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) log.Printf("Server starting on %s", addr) diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go new file mode 100644 index 0000000..5f4cd54 --- /dev/null +++ b/internal/infrastructure/http/dto.go @@ -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"` +} diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go new file mode 100644 index 0000000..fbf5431 --- /dev/null +++ b/internal/infrastructure/http/habit_handlers.go @@ -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}) +} diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 760ab25..8390d45 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -8,7 +8,7 @@ import ( "github.com/go-chi/cors" ) -func NewRouter(corsOrigins string) *chi.Mux { +func NewRouter(corsOrigins string, habitHandlers *HabitHandlers) *chi.Mux { r := chi.NewRouter() r.Use(middleware.Logger) @@ -25,5 +25,11 @@ func NewRouter(corsOrigins string) *chi.Mux { 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 } diff --git a/internal/infrastructure/persistence/sqlite/habit_entry_repository.go b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go index e689ff6..f50af3f 100644 --- a/internal/infrastructure/persistence/sqlite/habit_entry_repository.go +++ b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go @@ -134,3 +134,100 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt 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 +} diff --git a/internal/infrastructure/persistence/sqlite/habit_repository.go b/internal/infrastructure/persistence/sqlite/habit_repository.go index 48836a2..baa3528 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository.go @@ -206,3 +206,38 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error) 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 +}