Files
apocapoc-api/internal/infrastructure/http/habit_handlers.go
T
david 10d45fc34e Add COUNTER type with auto-increment and IsNegative field
COUNTER type:
- Only accepts integer values (rejects decimals)
- Auto-increment behavior: each mark adds to existing value
- Supports decrement via negative values (e.g., -2)
- Enforces minimum value of 0 (never negative)
- Default increment is 1 if no value provided

VALUE type:
- Accepts decimal values
- Replaces value (no auto-increment)

IsNegative field:
- New boolean field on Habit entity
- Persisted in database (is_negative column)
- Available in all DTOs and HTTP responses
- Marks habits as "negative" (e.g., candy consumption)

Examples:
- COUNTER: 5 + (-2) = 3, 2 + (-3) = 0, 0 + (-1) = 0
- VALUE: 5000 → 12000 = 12000 (replacement)
2025-11-27 08:58:39 +01:00

593 lines
17 KiB
Go

package http
import (
"encoding/json"
"net/http"
"strconv"
"time"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/shared/errors"
"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
}
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,
) *HabitHandlers {
return &HabitHandlers{
createHandler: createHandler,
getTodaysHandler: getTodaysHandler,
getUserHabitsHandler: getUserHabitsHandler,
getHabitByIDHandler: getHabitByIDHandler,
getHabitEntriesHandler: getHabitEntriesHandler,
updateHandler: updateHandler,
archiveHandler: archiveHandler,
markHandler: markHandler,
unmarkHandler: unmarkHandler,
}
}
// 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 {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, 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
}
respondError(w, http.StatusInternalServerError, "Failed to 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
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Success 200 {array} UserHabitResponse
// @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 {
respondError(w, http.StatusUnauthorized, "User not authenticated")
return
}
query := queries.GetUserHabitsQuery{
UserID: userID,
}
habits, err := h.getUserHabitsHandler.Handle(r.Context(), query)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get habits")
return
}
response := make([]UserHabitResponse, len(habits))
for i, habit := range habits {
response[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,
}
}
respondJSON(w, http.StatusOK, response)
}
// 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 {
respondError(w, 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 {
respondError(w, http.StatusNotFound, "Habit not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to 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 {
respondError(w, http.StatusUnauthorized, "User not authenticated")
return
}
var req UpdateHabitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
cmd := commands.UpdateHabitCommand{
HabitID: habitID,
UserID: userID,
Name: req.Name,
Description: req.Description,
CarryOver: req.CarryOver,
TargetValue: req.TargetValue,
SpecificDays: req.SpecificDays,
SpecificDates: req.SpecificDates,
}
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid input")
return
}
respondError(w, http.StatusInternalServerError, "Failed to 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 {
respondError(w, 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 {
respondError(w, http.StatusNotFound, "Habit not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to 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 {
respondError(w, 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 {
respondError(w, http.StatusBadRequest, "Invalid 'from' date format (use YYYY-MM-DD)")
return
}
query.From = &from
}
if toStr := r.URL.Query().Get("to"); toStr != "" {
to, err := time.Parse("2006-01-02", toStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid 'to' date format (use YYYY-MM-DD)")
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 {
respondError(w, 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 {
respondError(w, http.StatusBadRequest, "Invalid 'limit' parameter (must be 1-100)")
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 {
respondError(w, http.StatusNotFound, "Habit not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to 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
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Success 200 {array} TodaysHabitResponse
// @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 {
respondError(w, http.StatusUnauthorized, "User not authenticated")
return
}
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,
IsNegative: habit.IsNegative,
ScheduledDate: habit.ScheduledDate,
IsCarriedOver: habit.IsCarriedOver,
}
}
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 {
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"})
}
// 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 {
respondError(w, http.StatusUnauthorized, "User not authenticated")
return
}
scheduledDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
return
}
cmd := commands.UnmarkHabitCommand{
HabitID: habitID,
UserID: userID,
ScheduledDate: scheduledDate,
}
if err := h.unmarkHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit entry not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to 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})
}