package http import ( "encoding/json" "net/http" "strconv" "strings" "time" "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" "apocapoc-api/internal/domain/value_objects" "apocapoc-api/internal/i18n" "apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/pagination" "github.com/go-chi/chi/v5" ) type HabitHandlers struct { createHandler *commands.CreateHabitHandler getTodaysHandler *queries.GetTodaysHabitsHandler getUserHabitsHandler *queries.GetUserHabitsHandler getHabitByIDHandler *queries.GetHabitByIDHandler getHabitEntriesHandler *queries.GetHabitEntriesHandler updateHandler *commands.UpdateHabitHandler archiveHandler *commands.ArchiveHabitHandler markHandler *commands.MarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler translator *i18n.Translator } func NewHabitHandlers( createHandler *commands.CreateHabitHandler, getTodaysHandler *queries.GetTodaysHabitsHandler, getUserHabitsHandler *queries.GetUserHabitsHandler, getHabitByIDHandler *queries.GetHabitByIDHandler, getHabitEntriesHandler *queries.GetHabitEntriesHandler, updateHandler *commands.UpdateHabitHandler, archiveHandler *commands.ArchiveHabitHandler, markHandler *commands.MarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler, translator *i18n.Translator, ) *HabitHandlers { return &HabitHandlers{ createHandler: createHandler, getTodaysHandler: getTodaysHandler, getUserHabitsHandler: getUserHabitsHandler, getHabitByIDHandler: getHabitByIDHandler, getHabitEntriesHandler: getHabitEntriesHandler, updateHandler: updateHandler, archiveHandler: archiveHandler, markHandler: markHandler, unmarkHandler: unmarkHandler, translator: translator, } } // CreateHabit godoc // @Summary Create a new habit // @Description Create a new habit for the authenticated user // @Tags habits // @Accept json // @Produce json // @Security BearerAuth // @Param request body CreateHabitRequest true "Habit data" // @Success 201 {object} map[string]string // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits [post] func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { var req CreateHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } 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, IsNegative: req.IsNegative, 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 } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit") return } respondJSON(w, http.StatusCreated, map[string]string{"id": habitID}) } // GetUserHabits godoc // @Summary Get all user habits // @Description Get all active habits for the authenticated user with optional pagination and filters // @Tags habits // @Produce json // @Security BearerAuth // @Param page query int false "Page number (default: 1)" // @Param page_size query int false "Page size (default: 50, max: 100)" // @Param type query string false "Filter by type (BOOLEAN, COUNTER, VALUE)" // @Param frequency query string false "Filter by frequency (DAILY, WEEKLY, MONTHLY)" // @Param archived query boolean false "Include archived habits (default: false)" // @Param search query string false "Search by name or description" // @Success 200 {object} GetUserHabitsResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits [get] func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } query := queries.GetUserHabitsQuery{ UserID: userID, } pageStr := r.URL.Query().Get("page") pageSizeStr := r.URL.Query().Get("page_size") if pageStr != "" || pageSizeStr != "" { page := 1 pageSize := 50 if pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } if pageSizeStr != "" { if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 { pageSize = ps } } params := pagination.NewParams(page, pageSize) query.PaginationParams = ¶ms } typeStr := r.URL.Query().Get("type") frequencyStr := r.URL.Query().Get("frequency") archivedStr := r.URL.Query().Get("archived") searchStr := r.URL.Query().Get("search") if typeStr != "" || frequencyStr != "" || archivedStr != "" || searchStr != "" { filterParams := &queries.FilterParams{} if typeStr != "" { habitType := value_objects.HabitType(typeStr) if habitType.IsValid() { filterParams.Type = &habitType } } if frequencyStr != "" { frequency := value_objects.Frequency(frequencyStr) if frequency.IsValid() { filterParams.Frequency = &frequency } } if archivedStr == "true" { filterParams.IncludeArchived = true } if searchStr != "" { filterParams.Search = searchStr } query.FilterParams = filterParams } result, err := h.getUserHabitsHandler.Handle(r.Context(), query) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits") return } habitResponses := make([]UserHabitResponse, len(result.Habits)) for i, habit := range result.Habits { habitResponses[i] = UserHabitResponse{ ID: habit.ID, Name: habit.Name, Type: habit.Type, Frequency: habit.Frequency, SpecificDays: habit.SpecificDays, TargetValue: habit.TargetValue, CarryOver: habit.CarryOver, IsNegative: habit.IsNegative, } } if result.Pagination != nil { response := GetUserHabitsResponse{ Data: habitResponses, Pagination: result.Pagination, } respondJSON(w, http.StatusOK, response) } else { respondJSON(w, http.StatusOK, habitResponses) } } // GetHabitByID godoc // @Summary Get habit by ID // @Description Get a specific habit by ID // @Tags habits // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Success 200 {object} UserHabitResponse // @Failure 401 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id} [get] func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } query := queries.GetHabitByIDQuery{ HabitID: habitID, UserID: userID, } habit, err := h.getHabitByIDHandler.Handle(r.Context(), query) if err != nil { if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit") return } response := UserHabitResponse{ ID: habit.ID, Name: habit.Name, Type: habit.Type, Frequency: habit.Frequency, SpecificDays: habit.SpecificDays, TargetValue: habit.TargetValue, CarryOver: habit.CarryOver, IsNegative: habit.IsNegative, } respondJSON(w, http.StatusOK, response) } // UpdateHabit godoc // @Summary Update habit // @Description Update an existing habit // @Tags habits // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Param request body UpdateHabitRequest true "Update data" // @Success 200 {object} map[string]string // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id} [put] func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } var req UpdateHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } cmd := commands.UpdateHabitCommand{ HabitID: habitID, UserID: userID, Name: req.Name, Description: req.Description, Frequency: req.Frequency, SpecificDays: req.SpecificDays, SpecificDates: req.SpecificDates, CarryOver: req.CarryOver, TargetValue: req.TargetValue, } if err := h.updateHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } if err == errors.ErrInvalidInput { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_update_habit") return } respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } // ArchiveHabit godoc // @Summary Archive habit // @Description Archive (soft delete) a habit // @Tags habits // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Success 200 {object} map[string]string // @Failure 401 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id} [delete] func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } cmd := commands.ArchiveHabitCommand{ HabitID: habitID, UserID: userID, } if err := h.archiveHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_archive_habit") return } respondJSON(w, http.StatusOK, map[string]string{"status": "archived"}) } // GetHabitEntries godoc // @Summary Get habit entries // @Description Get entries (completion history) for a habit with optional date filtering and pagination // @Tags habits // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Param page query int false "Page number" // @Param limit query int false "Page size (max 100)" // @Success 200 {object} HabitEntriesResponse // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id}/entries [get] func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } query := queries.GetHabitEntriesQuery{ HabitID: habitID, UserID: userID, } if fromStr := r.URL.Query().Get("from"); fromStr != "" { from, err := time.Parse("2006-01-02", fromStr) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_from_date_format") return } query.From = &from } if toStr := r.URL.Query().Get("to"); toStr != "" { to, err := time.Parse("2006-01-02", toStr) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_to_date_format") return } query.To = &to } var dateRangeDays int if query.From != nil && query.To != nil { dateRangeDays = int(query.To.Sub(*query.From).Hours() / 24) } requiresPagination := false if query.From == nil || query.To == nil { requiresPagination = true } else if dateRangeDays > 365 { requiresPagination = true } if pageStr := r.URL.Query().Get("page"); pageStr != "" { page, err := strconv.Atoi(pageStr) if err != nil || page < 1 { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_page_parameter") return } query.Page = page } else if requiresPagination { query.Page = 1 } if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 || limit > 100 { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_limit_parameter") return } query.Limit = limit } else if requiresPagination { query.Limit = 50 } if requiresPagination && query.Limit == 0 { respondError(w, http.StatusBadRequest, "Pagination required: provide 'limit' parameter or use date range \u2264 1 year") return } result, err := h.getHabitEntriesHandler.Handle(r.Context(), query) if err != nil { if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit_entries") return } entries := make([]HabitEntryResponse, len(result.Entries)) for i, entry := range result.Entries { entries[i] = HabitEntryResponse{ ID: entry.ID, HabitID: entry.HabitID, ScheduledDate: entry.ScheduledDate, CompletedAt: entry.CompletedAt, Value: entry.Value, } } response := HabitEntriesResponse{ Entries: entries, Total: result.Total, Page: result.Page, Limit: result.Limit, } respondJSON(w, http.StatusOK, response) } // GetTodaysHabits godoc // @Summary Get today's habits // @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York). // @Tags habits // @Produce json // @Security BearerAuth // @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')" // @Success 200 {array} TodaysHabitResponse // @Failure 400 {object} ErrorResponse "Invalid or missing timezone" // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/today [get] func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } timezone := r.URL.Query().Get("timezone") if timezone == "" { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required") return } loc, err := time.LoadLocation(timezone) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone") return } today := time.Now().In(loc) todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) query := queries.GetTodaysHabitsQuery{ UserID: userID, Timezone: timezone, Date: todayDate, } habits, err := h.getTodaysHandler.Handle(r.Context(), query) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits") return } response := make([]TodaysHabitResponse, len(habits)) for i, habit := range habits { var entryResponse *TodaysHabitEntryResponse if habit.Entry != nil { entryResponse = &TodaysHabitEntryResponse{ ID: habit.Entry.ID, Value: habit.Entry.Value, CompletedAt: habit.Entry.CompletedAt, } } response[i] = TodaysHabitResponse{ ID: habit.ID, Name: habit.Name, Type: habit.Type, TargetValue: habit.TargetValue, IsNegative: habit.IsNegative, ScheduledDate: habit.ScheduledDate, IsCarriedOver: habit.IsCarriedOver, Entry: entryResponse, } } respondJSON(w, http.StatusOK, response) } // MarkHabit godoc // @Summary Mark habit as complete // @Description Mark a habit as completed for a specific date // @Tags habits // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Param request body MarkHabitRequest true "Mark data" // @Success 200 {object} map[string]string // @Failure 400 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 409 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id}/mark [post] 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 { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format") 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 { respondErrorI18n(w, r, h.translator, http.StatusConflict, "habit_already_marked") return } if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_mark_habit") return } respondJSON(w, http.StatusOK, map[string]string{"status": "marked"}) } // UnmarkHabit godoc // @Summary Unmark habit // @Description Delete a habit entry (unmark completion) // @Tags habits // @Produce json // @Security BearerAuth // @Param id path string true "Habit ID" // @Param date path string true "Date (YYYY-MM-DD)" // @Success 200 {object} map[string]string // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 403 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/{id}/entries/{date} [delete] func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") dateStr := chi.URLParam(r, "date") userID, ok := GetUserIDFromContext(r.Context()) if !ok { respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } scheduledDate, err := time.Parse("2006-01-02", dateStr) if err != nil { respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format") return } cmd := commands.UnmarkHabitCommand{ HabitID: habitID, UserID: userID, ScheduledDate: scheduledDate, } if err := h.unmarkHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_entry_not_found") return } if err == errors.ErrUnauthorized { respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_unmark_habit") return } respondJSON(w, http.StatusOK, map[string]string{"status": "unmarked"}) } 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}) } func respondValidationError(w http.ResponseWriter, err error) { errMsg := err.Error() var field string if strings.Contains(errMsg, ": ") { parts := strings.SplitN(errMsg, ": ", 3) if len(parts) >= 3 { field = parts[1] errMsg = parts[2] respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{ Error: errMsg, Field: field, }) return } } respondJSON(w, http.StatusBadRequest, ErrorResponse{ Error: errMsg, }) }