Complete CRUD operations for habits
Implement all missing endpoints for full habit management:
- GET /api/v1/habits - List all user habits
- GET /api/v1/habits/{id} - Get specific habit
- PUT /api/v1/habits/{id} - Update habit
- DELETE /api/v1/habits/{id} - Archive habit (soft delete)
- GET /api/v1/habits/{id}/entries - Get habit entry history
- DELETE /api/v1/habits/{id}/entries/{date} - Unmark habit (soft delete entry)
All endpoints include:
- TDD approach with comprehensive test coverage
- JWT authentication and ownership validation
- Proper error handling (404, 403, 400, 500)
- Clean architecture with separated commands/queries
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user