Add filtering support to GET /api/v1/habits endpoint

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
This commit is contained in:
2025-12-02 01:02:39 +01:00
parent 38e640c617
commit 935f742ac9
18 changed files with 662 additions and 2 deletions
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -238,3 +239,11 @@ func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, us
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -141,3 +142,11 @@ func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Contex
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockDeleteUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockDeleteUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -554,3 +555,19 @@ func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Cont
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockEntryRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"errors" "errors"
@@ -304,3 +305,11 @@ func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, use
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -351,3 +352,19 @@ func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockRequestResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockRequestResetUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -396,3 +397,19 @@ func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockPasswordResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockPasswordResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockResetPasswordUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockResetPasswordUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -195,3 +196,11 @@ func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Cont
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockRefreshTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -146,3 +147,11 @@ func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Co
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -345,3 +346,11 @@ func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.C
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockVerifyEmailUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockVerifyEmailUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package queries package queries
import ( import (
"apocapoc-api/internal/domain/repositories"
"context" "context"
"testing" "testing"
"time" "time"
@@ -324,3 +325,11 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) {
t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results)) t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results))
} }
} }
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -20,9 +20,17 @@ type HabitDTO struct {
SpecificDays []int SpecificDays []int
} }
type FilterParams struct {
Type *value_objects.HabitType
Frequency *value_objects.Frequency
IncludeArchived bool
Search string
}
type GetUserHabitsQuery struct { type GetUserHabitsQuery struct {
UserID string UserID string
PaginationParams *pagination.Params PaginationParams *pagination.Params
FilterParams *FilterParams
} }
type GetUserHabitsResult struct { type GetUserHabitsResult struct {
@@ -45,7 +53,29 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
var paginationResponse *pagination.Response var paginationResponse *pagination.Response
var err error var err error
if query.FilterParams != nil {
filter := repositories.HabitFilter{
Type: query.FilterParams.Type,
Frequency: query.FilterParams.Frequency,
IncludeArchived: query.FilterParams.IncludeArchived,
Search: query.FilterParams.Search,
}
habits, err = h.habitRepo.FindByUserIDFiltered(ctx, query.UserID, filter, query.PaginationParams)
if err != nil {
return nil, err
}
if query.PaginationParams != nil { if query.PaginationParams != nil {
totalItems, err := h.habitRepo.CountByUserIDFiltered(ctx, query.UserID, filter)
if err != nil {
return nil, err
}
response := pagination.NewResponse(*query.PaginationParams, totalItems)
paginationResponse = &response
}
} else if query.PaginationParams != nil {
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams) habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1,6 +1,7 @@
package queries package queries
import ( import (
"apocapoc-api/internal/domain/repositories"
"context" "context"
"testing" "testing"
@@ -277,3 +278,142 @@ func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
} }
}) })
} }
func (m *mockGetUserHabitsRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
var filtered []*entities.Habit
for _, habit := range m.habits {
if filter.Type != nil && habit.Type != *filter.Type {
continue
}
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
continue
}
if !filter.IncludeArchived && habit.ArchivedAt != nil {
continue
}
filtered = append(filtered, habit)
}
if paginationParams != nil {
offset := paginationParams.Offset()
limit := paginationParams.Limit()
if offset >= len(filtered) {
return []*entities.Habit{}, nil
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end], nil
}
return filtered, nil
}
func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
count := 0
for _, habit := range m.habits {
if filter.Type != nil && habit.Type != *filter.Type {
continue
}
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
continue
}
if !filter.IncludeArchived && habit.ArchivedAt != nil {
continue
}
count++
}
return count, nil
}
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit1.ID = "habit-1"
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
habit2.ID = "habit-2"
habit3 := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
habit3.ID = "habit-3"
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2, habit3}}
handler := NewGetUserHabitsHandler(habitRepo)
t.Run("FilterByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Type: &habitType,
},
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 1 {
t.Errorf("Expected 1 BOOLEAN habit, got %d", len(result.Habits))
}
if result.Habits[0].Type != value_objects.HabitTypeBoolean {
t.Errorf("Expected BOOLEAN type, got %s", result.Habits[0].Type)
}
})
t.Run("FilterByFrequency", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Frequency: &frequency,
},
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 2 {
t.Errorf("Expected 2 DAILY habits, got %d", len(result.Habits))
}
})
t.Run("FilterWithPagination", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
params := pagination.NewParams(1, 1)
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Frequency: &frequency,
},
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 1 {
t.Errorf("Expected 1 habit on first page, got %d", len(result.Habits))
}
if result.Pagination == nil {
t.Fatal("Expected pagination metadata")
}
if result.Pagination.TotalItems != 2 {
t.Errorf("Expected 2 total DAILY habits, got %d", result.Pagination.TotalItems)
}
})
}
@@ -1,6 +1,7 @@
package queries package queries
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -213,3 +214,11 @@ func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockLoginUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockLoginUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package queries package queries
import ( import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
"context" "context"
"testing" "testing"
@@ -283,3 +284,19 @@ func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx cont
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) { func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil return 0, nil
} }
func (m *mockRefreshTokenRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockUserRepositoryForRefresh) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -4,16 +4,26 @@ import (
"context" "context"
"apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
) )
type HabitFilter struct {
Type *value_objects.HabitType
Frequency *value_objects.Frequency
IncludeArchived bool
Search string
}
type HabitRepository interface { type HabitRepository interface {
Create(ctx context.Context, habit *entities.Habit) error Create(ctx context.Context, habit *entities.Habit) error
FindByID(ctx context.Context, id string) (*entities.Habit, error) FindByID(ctx context.Context, id string) (*entities.Habit, error)
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error)
FindByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error)
CountActiveByUserID(ctx context.Context, userID string) (int, error) CountActiveByUserID(ctx context.Context, userID string) (int, error)
CountByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter) (int, error)
Update(ctx context.Context, habit *entities.Habit) error Update(ctx context.Context, habit *entities.Habit) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
} }
+39 -1
View File
@@ -9,6 +9,7 @@ import (
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/i18n" "apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
@@ -109,12 +110,16 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
// GetUserHabits godoc // GetUserHabits godoc
// @Summary Get all user habits // @Summary Get all user habits
// @Description Get all active habits for the authenticated user with optional pagination // @Description Get all active habits for the authenticated user with optional pagination and filters
// @Tags habits // @Tags habits
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param page query int false "Page number (default: 1)" // @Param page query int false "Page number (default: 1)"
// @Param page_size query int false "Page size (default: 50, max: 100)" // @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 // @Success 200 {object} GetUserHabitsResponse
// @Failure 401 {object} ErrorResponse // @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
@@ -153,6 +158,39 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
query.PaginationParams = &params query.PaginationParams = &params
} }
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) result, err := h.getUserHabitsHandler.Handle(r.Context(), query)
if err != nil { if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits") respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
@@ -282,3 +283,93 @@ func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string
return count, nil return count, nil
} }
func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
baseQuery := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
FROM habits
WHERE user_id = ?`
args := []interface{}{userID}
conditions := []string{}
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at IS NULL")
}
if filter.Type != nil {
conditions = append(conditions, "type = ?")
args = append(args, string(*filter.Type))
}
if filter.Frequency != nil {
conditions = append(conditions, "frequency = ?")
args = append(args, string(*filter.Frequency))
}
if filter.Search != "" {
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
searchPattern := "%" + filter.Search + "%"
args = append(args, searchPattern, searchPattern)
}
for _, condition := range conditions {
baseQuery += " AND " + condition
}
baseQuery += " ORDER BY created_at DESC"
if paginationParams != nil {
baseQuery += " LIMIT ? OFFSET ?"
args = append(args, paginationParams.Limit(), paginationParams.Offset())
}
rows, err := r.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to find habits: %w", err)
}
defer rows.Close()
return r.scanHabits(rows)
}
func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
baseQuery := `SELECT COUNT(*) FROM habits WHERE user_id = ?`
args := []interface{}{userID}
conditions := []string{}
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at IS NULL")
}
if filter.Type != nil {
conditions = append(conditions, "type = ?")
args = append(args, string(*filter.Type))
}
if filter.Frequency != nil {
conditions = append(conditions, "frequency = ?")
args = append(args, string(*filter.Frequency))
}
if filter.Search != "" {
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
searchPattern := "%" + filter.Search + "%"
args = append(args, searchPattern, searchPattern)
}
for _, condition := range conditions {
baseQuery += " AND " + condition
}
var count int
err := r.db.QueryRowContext(ctx, baseQuery, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count habits: %w", err)
}
return count, nil
}
@@ -6,6 +6,7 @@ import (
"time" "time"
"apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects" "apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination" "apocapoc-api/internal/shared/pagination"
@@ -422,3 +423,213 @@ func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
} }
}) })
} }
func TestHabitRepositoryFindByUserIDFiltered(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-filter-test"
habit1 := entities.NewHabit(userID, "Morning Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit1.Description = "Daily morning workout"
repo.Create(ctx, habit1)
time.Sleep(1 * time.Millisecond)
habit2 := entities.NewHabit(userID, "Read Books", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
habit2.Description = "Read at least 3 books per week"
repo.Create(ctx, habit2)
time.Sleep(1 * time.Millisecond)
habit3 := entities.NewHabit(userID, "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
habit3.Description = "Drink 2 liters of water daily"
repo.Create(ctx, habit3)
time.Sleep(1 * time.Millisecond)
habit4 := entities.NewHabit(userID, "Weekly Run", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
repo.Create(ctx, habit4)
time.Sleep(1 * time.Millisecond)
now := time.Now()
habit4.ArchivedAt = &now
repo.Update(ctx, habit4)
t.Run("FilterByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 active BOOLEAN habit, got %d", len(habits))
}
if habits[0].Type != value_objects.HabitTypeBoolean {
t.Errorf("Expected BOOLEAN type, got %s", habits[0].Type)
}
})
t.Run("FilterByFrequency", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
filter := repositories.HabitFilter{
Frequency: &frequency,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 2 {
t.Errorf("Expected 2 DAILY habits, got %d", len(habits))
}
})
t.Run("FilterIncludeArchived", func(t *testing.T) {
filter := repositories.HabitFilter{
IncludeArchived: true,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 4 {
t.Errorf("Expected 4 habits (including archived), got %d", len(habits))
}
})
t.Run("FilterBySearch", func(t *testing.T) {
filter := repositories.HabitFilter{
Search: "Exercise",
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 habit matching 'Exercise', got %d", len(habits))
}
if habits[0].Name != "Morning Exercise" {
t.Errorf("Expected 'Morning Exercise', got %s", habits[0].Name)
}
})
t.Run("FilterBySearchInDescription", func(t *testing.T) {
filter := repositories.HabitFilter{
Search: "books",
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 habit matching 'books' in description, got %d", len(habits))
}
})
t.Run("CombineFilters", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
frequency := value_objects.FrequencyWeekly
filter := repositories.HabitFilter{
Type: &habitType,
Frequency: &frequency,
IncludeArchived: true,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 BOOLEAN WEEKLY habit (archived), got %d", len(habits))
}
if habits[0].Name != "Weekly Run" {
t.Errorf("Expected 'Weekly Run', got %s", habits[0].Name)
}
})
t.Run("WithPagination", func(t *testing.T) {
filter := repositories.HabitFilter{}
params := pagination.NewParams(1, 2)
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, &params)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 2 {
t.Errorf("Expected 2 habits on first page, got %d", len(habits))
}
})
}
func TestHabitRepositoryCountByUserIDFiltered(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-count-filter-test"
habit1 := entities.NewHabit(userID, "Test1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
repo.Create(ctx, habit1)
habit2 := entities.NewHabit(userID, "Test2", value_objects.HabitTypeCounter, value_objects.FrequencyDaily, false, false)
repo.Create(ctx, habit2)
habit3 := entities.NewHabit(userID, "Test3", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
now := time.Now()
habit3.ArchivedAt = &now
repo.Create(ctx, habit3)
repo.Update(ctx, habit3)
t.Run("CountByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
}
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
if err != nil {
t.Fatalf("CountByUserIDFiltered failed: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 active BOOLEAN habit, got %d", count)
}
})
t.Run("CountWithArchived", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
IncludeArchived: true,
}
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
if err != nil {
t.Fatalf("CountByUserIDFiltered failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 BOOLEAN habits (including archived), got %d", count)
}
})
}