935f742ac9
Implemented comprehensive filtering capabilities for the habits list endpoint: - Filter by type (BOOLEAN, COUNTER, VALUE) - Filter by frequency (DAILY, WEEKLY, MONTHLY) - Filter by archived status - Text search in habit name and description - All filters can be combined - Filters work with pagination Technical changes: - Added FilterParams to GetUserHabitsQuery - Created HabitFilter struct in repository interface - Implemented dynamic SQL query building in SQLite repository - Updated HTTP handler to parse filter query parameters - Added comprehensive tests for repository and handler filtering - Updated all test mocks with new filter methods
717 lines
21 KiB
Go
717 lines
21 KiB
Go
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,
|
|
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 {
|
|
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,
|
|
})
|
|
}
|