diff --git a/internal/application/commands/create_habit_test.go b/internal/application/commands/create_habit_test.go index dee83d5..cc06227 100644 --- a/internal/application/commands/create_habit_test.go +++ b/internal/application/commands/create_habit_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -238,3 +239,11 @@ func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, us func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/delete_user_test.go b/internal/application/commands/delete_user_test.go index aabfe32..4bd7792 100644 --- a/internal/application/commands/delete_user_test.go +++ b/internal/application/commands/delete_user_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -141,3 +142,11 @@ func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Contex func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/mark_habit_test.go b/internal/application/commands/mark_habit_test.go index d05d67d..b2c5dac 100644 --- a/internal/application/commands/mark_habit_test.go +++ b/internal/application/commands/mark_habit_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -554,3 +555,19 @@ func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Cont func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index ad73d1a..e0c8593 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "errors" @@ -304,3 +305,11 @@ func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, use func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/request_password_reset_test.go b/internal/application/commands/request_password_reset_test.go index 2d07e21..1ee9ed5 100644 --- a/internal/application/commands/request_password_reset_test.go +++ b/internal/application/commands/request_password_reset_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -351,3 +352,19 @@ func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context. func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/reset_password_test.go b/internal/application/commands/reset_password_test.go index 029b909..b8f0da3 100644 --- a/internal/application/commands/reset_password_test.go +++ b/internal/application/commands/reset_password_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -396,3 +397,19 @@ func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/revoke_token_test.go b/internal/application/commands/revoke_token_test.go index 95fce51..aecda9a 100644 --- a/internal/application/commands/revoke_token_test.go +++ b/internal/application/commands/revoke_token_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -195,3 +196,11 @@ func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Cont func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/unmark_habit_test.go b/internal/application/commands/unmark_habit_test.go index eb91fea..1673a31 100644 --- a/internal/application/commands/unmark_habit_test.go +++ b/internal/application/commands/unmark_habit_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -146,3 +147,11 @@ func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Co func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go index 9700c2d..477b02f 100644 --- a/internal/application/commands/verify_email_test.go +++ b/internal/application/commands/verify_email_test.go @@ -1,6 +1,7 @@ package commands import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -345,3 +346,11 @@ func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.C func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/queries/get_todays_habits_test.go b/internal/application/queries/get_todays_habits_test.go index 8c7fbc3..4b2ae2f 100644 --- a/internal/application/queries/get_todays_habits_test.go +++ b/internal/application/queries/get_todays_habits_test.go @@ -1,6 +1,7 @@ package queries import ( + "apocapoc-api/internal/domain/repositories" "context" "testing" "time" @@ -324,3 +325,11 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) { 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 +} diff --git a/internal/application/queries/get_user_habits.go b/internal/application/queries/get_user_habits.go index c3094e2..fcc1f54 100644 --- a/internal/application/queries/get_user_habits.go +++ b/internal/application/queries/get_user_habits.go @@ -20,9 +20,17 @@ type HabitDTO struct { SpecificDays []int } +type FilterParams struct { + Type *value_objects.HabitType + Frequency *value_objects.Frequency + IncludeArchived bool + Search string +} + type GetUserHabitsQuery struct { UserID string PaginationParams *pagination.Params + FilterParams *FilterParams } type GetUserHabitsResult struct { @@ -45,7 +53,29 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu var paginationResponse *pagination.Response var err error - if query.PaginationParams != nil { + 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 { + 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) if err != nil { return nil, err diff --git a/internal/application/queries/get_user_habits_test.go b/internal/application/queries/get_user_habits_test.go index 6e6034d..c21eb11 100644 --- a/internal/application/queries/get_user_habits_test.go +++ b/internal/application/queries/get_user_habits_test.go @@ -1,6 +1,7 @@ package queries import ( + "apocapoc-api/internal/domain/repositories" "context" "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: ¶ms, + } + + 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) + } + }) +} diff --git a/internal/application/queries/login_user_test.go b/internal/application/queries/login_user_test.go index ea2845a..ba235a0 100644 --- a/internal/application/queries/login_user_test.go +++ b/internal/application/queries/login_user_test.go @@ -1,6 +1,7 @@ package queries import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -213,3 +214,11 @@ func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index 141497d..84c1252 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -1,6 +1,7 @@ package queries import ( + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/pagination" "context" "testing" @@ -283,3 +284,19 @@ func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx cont func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) { 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 +} diff --git a/internal/domain/repositories/habit_repository.go b/internal/domain/repositories/habit_repository.go index ea1be25..5cbf15d 100644 --- a/internal/domain/repositories/habit_repository.go +++ b/internal/domain/repositories/habit_repository.go @@ -4,16 +4,26 @@ import ( "context" "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" "apocapoc-api/internal/shared/pagination" ) +type HabitFilter struct { + Type *value_objects.HabitType + Frequency *value_objects.Frequency + IncludeArchived bool + Search string +} + type HabitRepository interface { Create(ctx context.Context, habit *entities.Habit) error FindByID(ctx context.Context, id string) (*entities.Habit, error) FindByUserID(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) + FindByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, 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 Delete(ctx context.Context, id string) error } diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 35f8c3b..20ec897 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -9,6 +9,7 @@ import ( "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" @@ -109,12 +110,16 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { // GetUserHabits godoc // @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 // @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 @@ -153,6 +158,39 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { 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") diff --git a/internal/infrastructure/persistence/sqlite/habit_repository.go b/internal/infrastructure/persistence/sqlite/habit_repository.go index 78427ee..b125d6b 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository.go @@ -7,6 +7,7 @@ import ( "fmt" "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/pagination" @@ -282,3 +283,93 @@ func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string 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 +} diff --git a/internal/infrastructure/persistence/sqlite/habit_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_repository_test.go index efc3891..a197001 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository_test.go @@ -6,6 +6,7 @@ import ( "time" "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/value_objects" "apocapoc-api/internal/shared/errors" "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, ¶ms) + 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) + } + }) +}