diff --git a/cmd/api/main.go b/cmd/api/main.go index 34fb22e..95b81cb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -42,10 +42,16 @@ func main() { loginHandler := queries.NewLoginUserHandler(userRepo) createHandler := commands.NewCreateHabitHandler(habitRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) + getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) + getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo) + getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo) + updateHandler := commands.NewUpdateHabitHandler(habitRepo) + archiveHandler := commands.NewArchiveHabitHandler(habitRepo) markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) + unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, jwtService) - habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, markHandler) + habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, jwtService) diff --git a/internal/application/commands/archive_habit.go b/internal/application/commands/archive_habit.go new file mode 100644 index 0000000..1a3354e --- /dev/null +++ b/internal/application/commands/archive_habit.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type ArchiveHabitCommand struct { + HabitID string + UserID string +} + +type ArchiveHabitHandler struct { + habitRepo repositories.HabitRepository +} + +func NewArchiveHabitHandler(habitRepo repositories.HabitRepository) *ArchiveHabitHandler { + return &ArchiveHabitHandler{ + habitRepo: habitRepo, + } +} + +func (h *ArchiveHabitHandler) Handle(ctx context.Context, cmd ArchiveHabitCommand) error { + // Find existing habit + habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID) + if err != nil { + return err + } + + // Check ownership + if habit.UserID != cmd.UserID { + return errors.ErrUnauthorized + } + + // Archive the habit (idempotent operation) + habit.Archive() + + // Save changes + return h.habitRepo.Update(ctx, habit) +} diff --git a/internal/application/commands/archive_habit_test.go b/internal/application/commands/archive_habit_test.go new file mode 100644 index 0000000..6bbf41a --- /dev/null +++ b/internal/application/commands/archive_habit_test.go @@ -0,0 +1,105 @@ +package commands + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" + "apocapoc-api/internal/shared/errors" +) + +func TestArchiveHabitHandler_ArchivesSuccessfully(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewArchiveHabitHandler(habitRepo) + + cmd := ArchiveHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + } + + err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if habitRepo.updatedHabit.ArchivedAt == nil { + t.Error("Expected habit to be archived") + } + + if habitRepo.updatedHabit.IsActive() { + t.Error("Expected habit to not be active") + } +} + +func TestArchiveHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoForUpdate{ + errorOnFind: errors.ErrNotFound, + } + + handler := NewArchiveHabitHandler(habitRepo) + + cmd := ArchiveHabitCommand{ + HabitID: "non-existent", + UserID: "user-123", + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestArchiveHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewArchiveHabitHandler(habitRepo) + + cmd := ArchiveHabitCommand{ + HabitID: "habit-1", + UserID: "user-456", // Different user + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrUnauthorized { + t.Errorf("Expected ErrUnauthorized, got %v", err) + } +} + +func TestArchiveHabitHandler_CanArchiveAlreadyArchivedHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + habit.Archive() + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewArchiveHabitHandler(habitRepo) + + cmd := ArchiveHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + } + + err := handler.Handle(context.Background(), cmd) + + // Should be idempotent - no error + if err != nil { + t.Fatalf("Expected no error for already archived habit, got %v", err) + } +} diff --git a/internal/application/commands/unmark_habit.go b/internal/application/commands/unmark_habit.go new file mode 100644 index 0000000..e565ffc --- /dev/null +++ b/internal/application/commands/unmark_habit.go @@ -0,0 +1,78 @@ +package commands + +import ( + "context" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type UnmarkHabitCommand struct { + HabitID string + UserID string + ScheduledDate time.Time +} + +type UnmarkHabitHandler struct { + habitRepo repositories.HabitRepository + entryRepo repositories.HabitEntryRepository +} + +func NewUnmarkHabitHandler( + habitRepo repositories.HabitRepository, + entryRepo repositories.HabitEntryRepository, +) *UnmarkHabitHandler { + return &UnmarkHabitHandler{ + habitRepo: habitRepo, + entryRepo: entryRepo, + } +} + +func (h *UnmarkHabitHandler) Handle(ctx context.Context, cmd UnmarkHabitCommand) error { + // Verify habit exists and user owns it + habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID) + if err != nil { + return err + } + + if habit.UserID != cmd.UserID { + return errors.ErrUnauthorized + } + + // Find the entry for the scheduled date + // We search within the same day + startOfDay := time.Date( + cmd.ScheduledDate.Year(), + cmd.ScheduledDate.Month(), + cmd.ScheduledDate.Day(), + 0, 0, 0, 0, + cmd.ScheduledDate.Location(), + ) + endOfDay := startOfDay.Add(24 * time.Hour) + + entries, err := h.entryRepo.FindByHabitIDAndDateRange(ctx, cmd.HabitID, startOfDay, endOfDay) + if err != nil { + return err + } + + // Find the active entry for this date + var targetEntry *entities.HabitEntry + for _, entry := range entries { + if entry.ScheduledDate.Equal(cmd.ScheduledDate) && entry.DeletedAt == nil { + targetEntry = entry + break + } + } + + if targetEntry == nil { + return errors.ErrNotFound + } + + // Soft delete the entry + now := time.Now() + targetEntry.DeletedAt = &now + + return h.entryRepo.Update(ctx, targetEntry) +} diff --git a/internal/application/commands/unmark_habit_test.go b/internal/application/commands/unmark_habit_test.go new file mode 100644 index 0000000..8b16bdc --- /dev/null +++ b/internal/application/commands/unmark_habit_test.go @@ -0,0 +1,178 @@ +package commands + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" + "apocapoc-api/internal/shared/errors" +) + +type mockEntryRepoForUnmark struct { + mockEntryRepo + entries []*entities.HabitEntry + updatedEntry *entities.HabitEntry + errorOnUpdate error +} + +func (m *mockEntryRepoForUnmark) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) { + return m.entries, nil +} + +func (m *mockEntryRepoForUnmark) Update(ctx context.Context, entry *entities.HabitEntry) error { + if m.errorOnUpdate != nil { + return m.errorOnUpdate + } + m.updatedEntry = entry + return nil +} + +func TestUnmarkHabitHandler_UnmarksSuccessfully(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + scheduledDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + entry := entities.NewHabitEntry("habit-1", scheduledDate, nil) + entry.ID = "entry-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoForUnmark{ + entries: []*entities.HabitEntry{entry}, + } + + handler := NewUnmarkHabitHandler(habitRepo, entryRepo) + + cmd := UnmarkHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + ScheduledDate: scheduledDate, + } + + err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if entryRepo.updatedEntry == nil { + t.Fatal("Expected entry to be updated") + } + + if entryRepo.updatedEntry.DeletedAt == nil { + t.Error("Expected entry to be soft deleted") + } +} + +func TestUnmarkHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoForUpdate{ + errorOnFind: errors.ErrNotFound, + } + + entryRepo := &mockEntryRepoForUnmark{} + + handler := NewUnmarkHabitHandler(habitRepo, entryRepo) + + cmd := UnmarkHabitCommand{ + HabitID: "non-existent", + UserID: "user-123", + ScheduledDate: time.Now(), + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestUnmarkHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoForUnmark{} + + handler := NewUnmarkHabitHandler(habitRepo, entryRepo) + + cmd := UnmarkHabitCommand{ + HabitID: "habit-1", + UserID: "user-456", // Different user + ScheduledDate: time.Now(), + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrUnauthorized { + t.Errorf("Expected ErrUnauthorized, got %v", err) + } +} + +func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + // No entries + entryRepo := &mockEntryRepoForUnmark{ + entries: []*entities.HabitEntry{}, + } + + handler := NewUnmarkHabitHandler(habitRepo, entryRepo) + + cmd := UnmarkHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + ScheduledDate: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound for missing entry, got %v", err) + } +} + +func TestUnmarkHabitHandler_IgnoresAlreadyDeletedEntry(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + scheduledDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + entry := entities.NewHabitEntry("habit-1", scheduledDate, nil) + entry.ID = "entry-1" + now := time.Now() + entry.DeletedAt = &now // Already deleted + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoForUnmark{ + entries: []*entities.HabitEntry{entry}, + } + + handler := NewUnmarkHabitHandler(habitRepo, entryRepo) + + cmd := UnmarkHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + ScheduledDate: scheduledDate, + } + + err := handler.Handle(context.Background(), cmd) + + // Should return not found since the active entry doesn't exist + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound for already deleted entry, got %v", err) + } +} diff --git a/internal/application/commands/update_habit.go b/internal/application/commands/update_habit.go new file mode 100644 index 0000000..0888a33 --- /dev/null +++ b/internal/application/commands/update_habit.go @@ -0,0 +1,64 @@ +package commands + +import ( + "context" + "strings" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type UpdateHabitCommand struct { + HabitID string + UserID string + Name string + Description string + CarryOver bool + TargetValue *float64 + SpecificDays []int + SpecificDates []int +} + +type UpdateHabitHandler struct { + habitRepo repositories.HabitRepository +} + +func NewUpdateHabitHandler(habitRepo repositories.HabitRepository) *UpdateHabitHandler { + return &UpdateHabitHandler{ + habitRepo: habitRepo, + } +} + +func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand) error { + // Validate input + if strings.TrimSpace(cmd.Name) == "" { + return errors.ErrInvalidInput + } + + // Find existing habit + habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID) + if err != nil { + return err + } + + // Check ownership + if habit.UserID != cmd.UserID { + return errors.ErrUnauthorized + } + + // Cannot update archived habits + if !habit.IsActive() { + return errors.ErrInvalidInput + } + + // Update fields + habit.Name = cmd.Name + habit.Description = cmd.Description + habit.CarryOver = cmd.CarryOver + habit.TargetValue = cmd.TargetValue + habit.SpecificDays = cmd.SpecificDays + habit.SpecificDates = cmd.SpecificDates + + // Save changes + return h.habitRepo.Update(ctx, habit) +} diff --git a/internal/application/commands/update_habit_test.go b/internal/application/commands/update_habit_test.go new file mode 100644 index 0000000..8962094 --- /dev/null +++ b/internal/application/commands/update_habit_test.go @@ -0,0 +1,171 @@ +package commands + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" + "apocapoc-api/internal/shared/errors" +) + +type mockHabitRepoForUpdate struct { + mockHabitRepo + habitToReturn *entities.Habit + errorOnFind error + errorOnUpdate error + updatedHabit *entities.Habit +} + +func (m *mockHabitRepoForUpdate) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + if m.errorOnFind != nil { + return nil, m.errorOnFind + } + return m.habitToReturn, nil +} + +func (m *mockHabitRepoForUpdate) Update(ctx context.Context, habit *entities.Habit) error { + if m.errorOnUpdate != nil { + return m.errorOnUpdate + } + m.updatedHabit = habit + return nil +} + +func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewUpdateHabitHandler(habitRepo) + + newTargetValue := 5.0 + cmd := UpdateHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + Name: "Morning Exercise", + Description: "Updated description", + CarryOver: true, + TargetValue: &newTargetValue, + SpecificDays: []int{1, 3, 5}, + } + + err := handler.Handle(context.Background(), cmd) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if habitRepo.updatedHabit.Name != "Morning Exercise" { + t.Errorf("Expected name to be updated to 'Morning Exercise', got %s", habitRepo.updatedHabit.Name) + } + + if habitRepo.updatedHabit.Description != "Updated description" { + t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description) + } + + if !habitRepo.updatedHabit.CarryOver { + t.Error("Expected CarryOver to be true") + } + + if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 { + t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue) + } + + if len(habitRepo.updatedHabit.SpecificDays) != 3 { + t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays)) + } +} + +func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoForUpdate{ + errorOnFind: errors.ErrNotFound, + } + + handler := NewUpdateHabitHandler(habitRepo) + + cmd := UpdateHabitCommand{ + HabitID: "non-existent", + UserID: "user-123", + Name: "Exercise", + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewUpdateHabitHandler(habitRepo) + + cmd := UpdateHabitCommand{ + HabitID: "habit-1", + UserID: "user-456", // Different user + Name: "Exercise", + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrUnauthorized { + t.Errorf("Expected ErrUnauthorized, got %v", err) + } +} + +func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + habit.Archive() + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewUpdateHabitHandler(habitRepo) + + cmd := UpdateHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + Name: "Updated Exercise", + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput for archived habit, got %v", err) + } +} + +func TestUpdateHabitHandler_ValidatesInput(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoForUpdate{ + habitToReturn: habit, + } + + handler := NewUpdateHabitHandler(habitRepo) + + cmd := UpdateHabitCommand{ + HabitID: "habit-1", + UserID: "user-123", + Name: "", // Empty name + } + + err := handler.Handle(context.Background(), cmd) + + if err != errors.ErrInvalidInput { + t.Errorf("Expected ErrInvalidInput for empty name, got %v", err) + } +} diff --git a/internal/application/queries/get_habit_by_id.go b/internal/application/queries/get_habit_by_id.go new file mode 100644 index 0000000..d18ec60 --- /dev/null +++ b/internal/application/queries/get_habit_by_id.go @@ -0,0 +1,44 @@ +package queries + +import ( + "context" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type GetHabitByIDQuery struct { + HabitID string + UserID string +} + +type GetHabitByIDHandler struct { + habitRepo repositories.HabitRepository +} + +func NewGetHabitByIDHandler(habitRepo repositories.HabitRepository) *GetHabitByIDHandler { + return &GetHabitByIDHandler{ + habitRepo: habitRepo, + } +} + +func (h *GetHabitByIDHandler) Handle(ctx context.Context, query GetHabitByIDQuery) (*HabitDTO, error) { + habit, err := h.habitRepo.FindByID(ctx, query.HabitID) + if err != nil { + return nil, err + } + + if habit.UserID != query.UserID { + return nil, errors.ErrUnauthorized + } + + return &HabitDTO{ + ID: habit.ID, + Name: habit.Name, + Type: string(habit.Type), + Frequency: string(habit.Frequency), + TargetValue: habit.TargetValue, + CarryOver: habit.CarryOver, + SpecificDays: habit.SpecificDays, + }, nil +} diff --git a/internal/application/queries/get_habit_by_id_test.go b/internal/application/queries/get_habit_by_id_test.go new file mode 100644 index 0000000..4af25a4 --- /dev/null +++ b/internal/application/queries/get_habit_by_id_test.go @@ -0,0 +1,104 @@ +package queries + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" + "apocapoc-api/internal/shared/errors" +) + +type mockHabitRepoWithFindByID struct { + mockHabitRepo + habitToReturn *entities.Habit + errorToReturn error +} + +func (m *mockHabitRepoWithFindByID) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + if m.errorToReturn != nil { + return nil, m.errorToReturn + } + return m.habitToReturn, nil +} + +func TestGetHabitByIDHandler_ReturnsHabitSuccessfully(t *testing.T) { + targetValue := 5.0 + habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true) + habit.ID = "habit-1" + habit.TargetValue = &targetValue + + habitRepo := &mockHabitRepoWithFindByID{ + habitToReturn: habit, + } + + handler := NewGetHabitByIDHandler(habitRepo) + + query := GetHabitByIDQuery{ + HabitID: "habit-1", + UserID: "user-123", + } + + result, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != "habit-1" { + t.Errorf("Expected ID habit-1, got %s", result.ID) + } + + if result.Name != "Drink Water" { + t.Errorf("Expected name 'Drink Water', got %s", result.Name) + } + + if result.Type != string(value_objects.HabitTypeQuantity) { + t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type) + } + + if result.TargetValue == nil || *result.TargetValue != 5.0 { + t.Errorf("Expected target value 5.0, got %v", result.TargetValue) + } +} + +func TestGetHabitByIDHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoWithFindByID{ + errorToReturn: errors.ErrNotFound, + } + + handler := NewGetHabitByIDHandler(habitRepo) + + query := GetHabitByIDQuery{ + HabitID: "non-existent", + UserID: "user-123", + } + + _, err := handler.Handle(context.Background(), query) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestGetHabitByIDHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoWithFindByID{ + habitToReturn: habit, + } + + handler := NewGetHabitByIDHandler(habitRepo) + + query := GetHabitByIDQuery{ + HabitID: "habit-1", + UserID: "user-456", // Different user + } + + _, err := handler.Handle(context.Background(), query) + + if err != errors.ErrUnauthorized { + t.Errorf("Expected ErrUnauthorized, got %v", err) + } +} diff --git a/internal/application/queries/get_habit_entries.go b/internal/application/queries/get_habit_entries.go new file mode 100644 index 0000000..7f8a000 --- /dev/null +++ b/internal/application/queries/get_habit_entries.go @@ -0,0 +1,73 @@ +package queries + +import ( + "context" + "time" + + "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/shared/errors" +) + +type HabitEntryDTO struct { + ID string + HabitID string + ScheduledDate time.Time + CompletedAt time.Time + Value *float64 +} + +type GetHabitEntriesQuery struct { + HabitID string + UserID string +} + +type GetHabitEntriesHandler struct { + habitRepo repositories.HabitRepository + entryRepo repositories.HabitEntryRepository +} + +func NewGetHabitEntriesHandler( + habitRepo repositories.HabitRepository, + entryRepo repositories.HabitEntryRepository, +) *GetHabitEntriesHandler { + return &GetHabitEntriesHandler{ + habitRepo: habitRepo, + entryRepo: entryRepo, + } +} + +func (h *GetHabitEntriesHandler) Handle(ctx context.Context, query GetHabitEntriesQuery) ([]HabitEntryDTO, error) { + // Verify habit exists and user owns it + habit, err := h.habitRepo.FindByID(ctx, query.HabitID) + if err != nil { + return nil, err + } + + if habit.UserID != query.UserID { + return nil, errors.ErrUnauthorized + } + + // Get all entries for the habit + entries, err := h.entryRepo.FindByHabitID(ctx, query.HabitID) + if err != nil { + return nil, err + } + + var result []HabitEntryDTO + for _, entry := range entries { + // Filter out deleted entries + if entry.DeletedAt != nil { + continue + } + + result = append(result, HabitEntryDTO{ + ID: entry.ID, + HabitID: entry.HabitID, + ScheduledDate: entry.ScheduledDate, + CompletedAt: entry.CompletedAt, + Value: entry.Value, + }) + } + + return result, nil +} diff --git a/internal/application/queries/get_habit_entries_test.go b/internal/application/queries/get_habit_entries_test.go new file mode 100644 index 0000000..43b502c --- /dev/null +++ b/internal/application/queries/get_habit_entries_test.go @@ -0,0 +1,158 @@ +package queries + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" + "apocapoc-api/internal/shared/errors" +) + +type mockEntryRepoWithFindByHabitID struct { + mockEntryRepo + entries []*entities.HabitEntry +} + +func (m *mockEntryRepoWithFindByHabitID) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) { + return m.entries, nil +} + +func TestGetHabitEntriesHandler_ReturnsEntriesSuccessfully(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + date1 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + date2 := time.Date(2025, 1, 16, 0, 0, 0, 0, time.UTC) + + entry1 := entities.NewHabitEntry("habit-1", date1, nil) + entry1.ID = "entry-1" + + entry2 := entities.NewHabitEntry("habit-1", date2, nil) + entry2.ID = "entry-2" + + habitRepo := &mockHabitRepoWithFindByID{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoWithFindByHabitID{ + entries: []*entities.HabitEntry{entry1, entry2}, + } + + handler := NewGetHabitEntriesHandler(habitRepo, entryRepo) + + query := GetHabitEntriesQuery{ + HabitID: "habit-1", + UserID: "user-123", + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 2 { + t.Fatalf("Expected 2 entries, got %d", len(results)) + } + + if results[0].ID != "entry-1" { + t.Errorf("Expected first entry ID entry-1, got %s", results[0].ID) + } + + if results[1].ID != "entry-2" { + t.Errorf("Expected second entry ID entry-2, got %s", results[1].ID) + } +} + +func TestGetHabitEntriesHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) { + habitRepo := &mockHabitRepoWithFindByID{ + errorToReturn: errors.ErrNotFound, + } + + entryRepo := &mockEntryRepoWithFindByHabitID{} + + handler := NewGetHabitEntriesHandler(habitRepo, entryRepo) + + query := GetHabitEntriesQuery{ + HabitID: "non-existent", + UserID: "user-123", + } + + _, err := handler.Handle(context.Background(), query) + + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestGetHabitEntriesHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + habitRepo := &mockHabitRepoWithFindByID{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoWithFindByHabitID{} + + handler := NewGetHabitEntriesHandler(habitRepo, entryRepo) + + query := GetHabitEntriesQuery{ + HabitID: "habit-1", + UserID: "user-456", // Different user + } + + _, err := handler.Handle(context.Background(), query) + + if err != errors.ErrUnauthorized { + t.Errorf("Expected ErrUnauthorized, got %v", err) + } +} + +func TestGetHabitEntriesHandler_FiltersDeletedEntries(t *testing.T) { + habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit.ID = "habit-1" + + date1 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + date2 := time.Date(2025, 1, 16, 0, 0, 0, 0, time.UTC) + + entry1 := entities.NewHabitEntry("habit-1", date1, nil) + entry1.ID = "entry-1" + + entry2 := entities.NewHabitEntry("habit-1", date2, nil) + entry2.ID = "entry-2" + now := time.Now() + entry2.DeletedAt = &now // This one is deleted + + habitRepo := &mockHabitRepoWithFindByID{ + habitToReturn: habit, + } + + entryRepo := &mockEntryRepoWithFindByHabitID{ + entries: []*entities.HabitEntry{entry1, entry2}, + } + + handler := NewGetHabitEntriesHandler(habitRepo, entryRepo) + + query := GetHabitEntriesQuery{ + HabitID: "habit-1", + UserID: "user-123", + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Should only return the non-deleted entry + if len(results) != 1 { + t.Fatalf("Expected 1 active entry, got %d", len(results)) + } + + if results[0].ID != "entry-1" { + t.Errorf("Expected entry-1 (non-deleted), got %s", results[0].ID) + } +} diff --git a/internal/application/queries/get_user_habits.go b/internal/application/queries/get_user_habits.go new file mode 100644 index 0000000..de29c2e --- /dev/null +++ b/internal/application/queries/get_user_habits.go @@ -0,0 +1,53 @@ +package queries + +import ( + "context" + + "apocapoc-api/internal/domain/repositories" +) + +type HabitDTO struct { + ID string + Name string + Type string + Frequency string + TargetValue *float64 + CarryOver bool + SpecificDays []int +} + +type GetUserHabitsQuery struct { + UserID string +} + +type GetUserHabitsHandler struct { + habitRepo repositories.HabitRepository +} + +func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHabitsHandler { + return &GetUserHabitsHandler{ + habitRepo: habitRepo, + } +} + +func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) { + habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID) + if err != nil { + return nil, err + } + + var result []HabitDTO + for _, habit := range habits { + result = append(result, HabitDTO{ + ID: habit.ID, + Name: habit.Name, + Type: string(habit.Type), + Frequency: string(habit.Frequency), + TargetValue: habit.TargetValue, + CarryOver: habit.CarryOver, + SpecificDays: habit.SpecificDays, + }) + } + + return result, nil +} diff --git a/internal/application/queries/get_user_habits_test.go b/internal/application/queries/get_user_habits_test.go new file mode 100644 index 0000000..59c4eed --- /dev/null +++ b/internal/application/queries/get_user_habits_test.go @@ -0,0 +1,110 @@ +package queries + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" +) + +func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) { + habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit1.ID = "habit-1" + + habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false) + habit2.ID = "habit-2" + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}} + + handler := NewGetUserHabitsHandler(habitRepo) + + query := GetUserHabitsQuery{ + UserID: "user-123", + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 2 { + t.Fatalf("Expected 2 habits, got %d", len(results)) + } + + if results[0].ID != "habit-1" { + t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID) + } + + if results[1].ID != "habit-2" { + t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID) + } +} + +func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) { + habitRepo := &mockHabitRepo{habits: []*entities.Habit{}} + + handler := NewGetUserHabitsHandler(habitRepo) + + query := GetUserHabitsQuery{ + UserID: "user-456", + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 0 { + t.Fatalf("Expected 0 habits, got %d", len(results)) + } +} + +func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) { + targetValue := 5.0 + habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true) + habit.ID = "habit-1" + habit.TargetValue = &targetValue + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + + handler := NewGetUserHabitsHandler(habitRepo) + + query := GetUserHabitsQuery{ + UserID: "user-123", + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 habit, got %d", len(results)) + } + + result := results[0] + + if result.Name != "Drink Water" { + t.Errorf("Expected name 'Drink Water', got %s", result.Name) + } + + if result.Type != string(value_objects.HabitTypeQuantity) { + t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type) + } + + if result.Frequency != string(value_objects.FrequencyDaily) { + t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency) + } + + if result.TargetValue == nil || *result.TargetValue != 5.0 { + t.Errorf("Expected target value 5.0, got %v", result.TargetValue) + } + + if !result.CarryOver { + t.Error("Expected carry over to be true") + } +} diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index 5f4cd54..79f5ee5 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -13,6 +13,15 @@ type CreateHabitRequest struct { TargetValue *float64 `json:"target_value,omitempty"` } +type UpdateHabitRequest struct { + Name string `json:"name"` + Description string `json:"description"` + SpecificDays []int `json:"specific_days,omitempty"` + SpecificDates []int `json:"specific_dates,omitempty"` + CarryOver bool `json:"carry_over"` + TargetValue *float64 `json:"target_value,omitempty"` +} + type HabitResponse struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -42,6 +51,24 @@ type TodaysHabitResponse struct { IsCarriedOver bool `json:"is_carried_over"` } +type UserHabitResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Frequency string `json:"frequency"` + SpecificDays []int `json:"specific_days,omitempty"` + TargetValue *float64 `json:"target_value,omitempty"` + CarryOver bool `json:"carry_over"` +} + +type HabitEntryResponse struct { + ID string `json:"id"` + HabitID string `json:"habit_id"` + ScheduledDate time.Time `json:"scheduled_date"` + CompletedAt time.Time `json:"completed_at"` + Value *float64 `json:"value,omitempty"` +} + type ErrorResponse struct { Error string `json:"error"` } diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 92a697b..3c3e1a4 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -13,20 +13,38 @@ import ( ) type HabitHandlers struct { - createHandler *commands.CreateHabitHandler - getTodaysHandler *queries.GetTodaysHabitsHandler - markHandler *commands.MarkHabitHandler + 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, - markHandler: markHandler, + createHandler: createHandler, + getTodaysHandler: getTodaysHandler, + getUserHabitsHandler: getUserHabitsHandler, + getHabitByIDHandler: getHabitByIDHandler, + getHabitEntriesHandler: getHabitEntriesHandler, + updateHandler: updateHandler, + archiveHandler: archiveHandler, + markHandler: markHandler, + unmarkHandler: unmarkHandler, } } @@ -68,6 +86,198 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, map[string]string{"id": habitID}) } +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, + } + } + + respondJSON(w, http.StatusOK, response) +} + +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, + } + + respondJSON(w, http.StatusOK, response) +} + +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"}) +} + +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"}) +} + +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, + } + + entries, 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 + } + + response := make([]HabitEntryResponse, len(entries)) + for i, entry := range entries { + response[i] = HabitEntryResponse{ + ID: entry.ID, + HabitID: entry.HabitID, + ScheduledDate: entry.ScheduledDate, + CompletedAt: entry.CompletedAt, + Value: entry.Value, + } + } + + respondJSON(w, http.StatusOK, response) +} + func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { @@ -141,6 +351,44 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "marked"}) } +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) diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index b7025b8..e7ce331 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -35,8 +35,14 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A r.Route("/api/v1/habits", func(r chi.Router) { r.Use(AuthMiddleware(jwtService)) r.Post("/", habitHandlers.CreateHabit) + r.Get("/", habitHandlers.GetUserHabits) r.Get("/today", habitHandlers.GetTodaysHabits) + r.Get("/{id}", habitHandlers.GetHabitByID) + r.Put("/{id}", habitHandlers.UpdateHabit) + r.Delete("/{id}", habitHandlers.ArchiveHabit) + r.Get("/{id}/entries", habitHandlers.GetHabitEntries) r.Post("/{id}/mark", habitHandlers.MarkHabit) + r.Delete("/{id}/entries/{date}", habitHandlers.UnmarkHabit) }) return r