Migrate from Gin to Echo framework and add comprehensive integration tests

Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -4,14 +4,15 @@ import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/validator"
)
// AuthHandler handles authentication endpoints
@@ -43,65 +44,38 @@ func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthServic
}
// Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c *gin.Context) {
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.Login(&req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.invalid_credentials")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
}
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Register handles POST /api/auth/register/
func (h *AuthHandler) Register(c *gin.Context) {
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, confirmationCode, err := h.authService.Register(&req)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrUsernameTaken) {
message = i18n.LocalizedMessage(c, "error.username_taken")
} else if errors.Is(err, services.ErrEmailTaken) {
message = i18n.LocalizedMessage(c, "error.email_taken")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.registration_failed")
log.Error().Err(err).Msg("Registration failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Registration failed")
return err
}
// Send welcome email with confirmation code (async)
@@ -113,15 +87,14 @@ func (h *AuthHandler) Register(c *gin.Context) {
}()
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// Logout handles POST /api/auth/logout/
func (h *AuthHandler) Logout(c *gin.Context) {
func (h *AuthHandler) Logout(c echo.Context) error {
token := middleware.GetAuthToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.not_authenticated")})
return
return apperrors.Unauthorized("error.not_authenticated")
}
// Invalidate token in database
@@ -131,101 +104,73 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// Invalidate token in cache
if h.cache != nil {
if err := h.cache.InvalidateAuthToken(c.Request.Context(), token); err != nil {
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate token in cache")
}
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.logged_out")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) CurrentUser(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
response, err := h.authService.GetCurrentUser(user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_get_user")})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UpdateProfile handles PUT/PATCH /api/auth/profile/
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.UpdateProfile(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrEmailTaken) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_taken")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_update_profile")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// VerifyEmail handles POST /api/auth/verify-email/
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) VerifyEmail(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.VerifyEmail(user.ID, req.Code)
err = h.authService.VerifyEmail(user.ID, req.Code)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrInvalidCode) {
message = i18n.LocalizedMessage(c, "error.invalid_verification_code")
} else if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrAlreadyVerified) {
message = i18n.LocalizedMessage(c, "error.email_already_verified")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.verification_failed")
log.Error().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
return err
}
// Send post-verification welcome email with tips (async)
@@ -237,29 +182,23 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: i18n.LocalizedMessage(c, "message.email_verified"),
return c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: "Email verified successfully",
Verified: true,
})
}
// ResendVerification handles POST /api/auth/resend-verification/
func (h *AuthHandler) ResendVerification(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) ResendVerification(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
code, err := h.authService.ResendVerificationCode(user.ID)
if err != nil {
if errors.Is(err, services.ErrAlreadyVerified) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_verified")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_resend_verification")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
return err
}
// Send verification email (async)
@@ -271,33 +210,29 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.verification_email_sent")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
}
// ForgotPassword handles POST /api/auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
func (h *AuthHandler) ForgotPassword(c echo.Context) error {
var req requests.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
code, user, err := h.authService.ForgotPassword(req.Email)
if err != nil {
if errors.Is(err, services.ErrRateLimitExceeded) {
c.JSON(http.StatusTooManyRequests, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.rate_limit_exceeded"),
})
return
var appErr *apperrors.AppError
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
// Only reveal rate limit errors
return err
}
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
// Don't reveal errors to prevent email enumeration
// Don't reveal other errors to prevent email enumeration
}
// Send password reset email (async) - only if user found
@@ -310,116 +245,82 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
}
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_email_sent"),
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "Password reset email sent",
})
}
// VerifyResetCode handles POST /api/auth/verify-reset-code/
func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
func (h *AuthHandler) VerifyResetCode(c echo.Context) error {
var req requests.VerifyResetCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_verification_code")
if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrRateLimitExceeded) {
status = http.StatusTooManyRequests
message = i18n.LocalizedMessage(c, "error.too_many_attempts")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed")
return err
}
c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: i18n.LocalizedMessage(c, "message.reset_code_verified"),
return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: "Reset code verified",
ResetToken: resetToken,
})
}
// ResetPassword handles POST /api/auth/reset-password/
func (h *AuthHandler) ResetPassword(c *gin.Context) {
func (h *AuthHandler) ResetPassword(c echo.Context) error {
var req requests.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.ResetPassword(req.ResetToken, req.NewPassword)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_reset_token")
if errors.Is(err, services.ErrInvalidResetToken) {
message = i18n.LocalizedMessage(c, "error.invalid_reset_token")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.password_reset_failed")
log.Error().Err(err).Msg("Password reset failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Password reset failed")
return err
}
c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_success"),
return c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: "Password reset successful",
})
}
// AppleSignIn handles POST /api/auth/apple-sign-in/
func (h *AuthHandler) AppleSignIn(c *gin.Context) {
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
var req requests.AppleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.appleAuthService == nil {
log.Error().Msg("Apple auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.apple_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.apple_signin_not_configured",
}
}
response, err := h.authService.AppleSignIn(c.Request.Context(), h.appleAuthService, &req)
response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.apple_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrAppleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_apple_token")
// Check for legacy Apple Sign In error (not yet migrated)
if errors.Is(err, services.ErrAppleSignInFailed) {
log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_apple_token")
}
log.Debug().Err(err).Msg("Apple Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -431,44 +332,37 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
var req requests.GoogleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.googleAuthService == nil {
log.Error().Msg("Google auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.google_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.google_signin_not_configured",
}
}
response, err := h.authService.GoogleSignIn(c.Request.Context(), h.googleAuthService, &req)
response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.google_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrGoogleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_google_token")
// Check for legacy Google Sign In error (not yet migrated)
if errors.Is(err, services.ErrGoogleSignInFailed) {
log.Debug().Err(err).Msg("Google Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_google_token")
}
log.Debug().Err(err).Msg("Google Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -480,5 +374,5 @@ func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,7 +17,7 @@ import (
"github.com/treytartt/casera-api/internal/testutil"
)
func setupAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *repositories.UserRepository) {
func setupAuthHandler(t *testing.T) (*AuthHandler, *echo.Echo, *repositories.UserRepository) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
@@ -30,14 +30,14 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *repositories.Us
}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil) // No email or cache for tests
router := testutil.SetupTestRouter()
return handler, router, userRepo
e := testutil.SetupTestRouter()
return handler, e, userRepo
}
func TestAuthHandler_Register(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/register/", handler.Register)
t.Run("successful registration", func(t *testing.T) {
req := requests.RegisterRequest{
@@ -48,7 +48,7 @@ func TestAuthHandler_Register(t *testing.T) {
LastName: "User",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -73,7 +73,7 @@ func TestAuthHandler_Register(t *testing.T) {
// Missing email and password
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
@@ -88,7 +88,7 @@ func TestAuthHandler_Register(t *testing.T) {
Password: "short", // Less than 8 chars
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
@@ -100,13 +100,13 @@ func TestAuthHandler_Register(t *testing.T) {
Email: "unique1@test.com",
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same username
req.Email = "unique2@test.com"
w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Username already taken")
@@ -119,13 +119,13 @@ func TestAuthHandler_Register(t *testing.T) {
Email: "duplicate@test.com",
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same email
req.Username = "user2"
w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Email already registered")
@@ -133,10 +133,10 @@ func TestAuthHandler_Register(t *testing.T) {
}
func TestAuthHandler_Login(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/login/", handler.Login)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
// Create a test user
registerReq := requests.RegisterRequest{
@@ -146,7 +146,7 @@ func TestAuthHandler_Login(t *testing.T) {
FirstName: "Test",
LastName: "User",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
t.Run("successful login with username", func(t *testing.T) {
@@ -155,7 +155,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -177,7 +177,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
@@ -188,7 +188,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "wrongpassword",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
@@ -202,7 +202,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
@@ -213,14 +213,14 @@ func TestAuthHandler_Login(t *testing.T) {
// Missing password
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_CurrentUser(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "password123")
@@ -229,12 +229,12 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
userRepo.Update(user)
// Set up route with mock auth middleware
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/me/", handler.CurrentUser)
t.Run("get current user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/auth/me/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/auth/me/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -248,13 +248,13 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
}
func TestAuthHandler_UpdateProfile(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "password123")
userRepo.Update(user)
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/profile/", handler.UpdateProfile)
@@ -266,7 +266,7 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
LastName: &lastName,
}
w := testutil.MakeRequest(router, "PUT", "/api/auth/profile/", req, "test-token")
w := testutil.MakeRequest(e, "PUT", "/api/auth/profile/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -280,10 +280,10 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
}
func TestAuthHandler_ForgotPassword(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/forgot-password/", handler.ForgotPassword)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
// Create a test user
registerReq := requests.RegisterRequest{
@@ -291,14 +291,14 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
Email: "forgot@test.com",
Password: "password123",
}
testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "")
testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
t.Run("forgot password with valid email", func(t *testing.T) {
req := requests.ForgotPasswordRequest{
Email: "forgot@test.com",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Always returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -312,7 +312,7 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
Email: "nonexistent@test.com",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Still returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -320,18 +320,18 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
}
func TestAuthHandler_Logout(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "logouttest", "logout@test.com", "password123")
userRepo.Update(user)
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/logout/", handler.Logout)
t.Run("successful logout", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", "/api/auth/logout/", nil, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/auth/logout/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -341,10 +341,10 @@ func TestAuthHandler_Logout(t *testing.T) {
}
func TestAuthHandler_JSONResponses(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/login/", handler.Login)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
t.Run("register response has correct JSON structure", func(t *testing.T) {
req := requests.RegisterRequest{
@@ -355,7 +355,7 @@ func TestAuthHandler_JSONResponses(t *testing.T) {
LastName: "Test",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -393,7 +393,7 @@ func TestAuthHandler_JSONResponses(t *testing.T) {
"username": "test",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)

View File

@@ -1,14 +1,13 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -25,190 +24,130 @@ func NewContractorHandler(contractorService *services.ContractorService) *Contra
}
// ListContractors handles GET /api/contractors/
func (h *ContractorHandler) ListContractors(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ListContractors(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.contractorService.ListContractors(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetContractor handles GET /api/contractors/:id/
func (h *ContractorHandler) GetContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) GetContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.GetContractor(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateContractor handles POST /api/contractors/
func (h *ContractorHandler) CreateContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) CreateContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.contractorService.CreateContractor(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateContractor handles PUT/PATCH /api/contractors/:id/
func (h *ContractorHandler) UpdateContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) UpdateContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
var req requests.UpdateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.contractorService.UpdateContractor(uint(contractorID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteContractor handles DELETE /api/contractors/:id/
func (h *ContractorHandler) DeleteContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) DeleteContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
err = h.contractorService.DeleteContractor(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.contractor_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Contractor deleted successfully"})
}
// ToggleFavorite handles POST /api/contractors/:id/toggle-favorite/
func (h *ContractorHandler) ToggleFavorite(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ToggleFavorite(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.ToggleFavorite(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetContractorTasks handles GET /api/contractors/:id/tasks/
func (h *ContractorHandler) GetContractorTasks(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) GetContractorTasks(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.GetContractorTasks(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListContractorsByResidence handles GET /api/contractors/by-residence/:residence_id/
func (h *ContractorHandler) ListContractorsByResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ListContractorsByResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.contractorService.ListContractorsByResidence(uint(residenceID), user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetSpecialties handles GET /api/contractors/specialties/
func (h *ContractorHandler) GetSpecialties(c *gin.Context) {
func (h *ContractorHandler) GetSpecialties(c echo.Context) error {
specialties, err := h.contractorService.GetSpecialties()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, specialties)
return c.JSON(http.StatusOK, specialties)
}

View File

@@ -1,18 +1,17 @@
package handlers
import (
"errors"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -33,101 +32,86 @@ func NewDocumentHandler(documentService *services.DocumentService, storageServic
}
// ListDocuments handles GET /api/documents/
func (h *DocumentHandler) ListDocuments(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.documentService.ListDocuments(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetDocument handles GET /api/documents/:id/
func (h *DocumentHandler) GetDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) GetDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.GetDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListWarranties handles GET /api/documents/warranties/
func (h *DocumentHandler) ListWarranties(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ListWarranties(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.documentService.ListWarranties(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateDocument handles POST /api/documents/
// Supports both JSON and multipart form data (for file uploads)
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) CreateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateDocumentRequest
contentType := c.GetHeader("Content-Type")
contentType := c.Request().Header.Get("Content-Type")
// Check if this is a multipart form request (file upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")})
return
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse residence_id (required)
residenceIDStr := c.PostForm("residence_id")
residenceIDStr := c.FormValue("residence_id")
if residenceIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_id_required")})
return
return apperrors.BadRequest("error.residence_id_required")
}
residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
req.ResidenceID = uint(residenceID)
// Parse title (required)
req.Title = c.PostForm("title")
req.Title = c.FormValue("title")
if req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.title_required")})
return
return apperrors.BadRequest("error.title_required")
}
// Parse optional fields
req.Description = c.PostForm("description")
req.Vendor = c.PostForm("vendor")
req.SerialNumber = c.PostForm("serial_number")
req.ModelNumber = c.PostForm("model_number")
req.Description = c.FormValue("description")
req.Vendor = c.FormValue("vendor")
req.SerialNumber = c.FormValue("serial_number")
req.ModelNumber = c.FormValue("model_number")
// Parse document_type
if docType := c.PostForm("document_type"); docType != "" {
if docType := c.FormValue("document_type"); docType != "" {
dt := models.DocumentType(docType)
req.DocumentType = dt
}
// Parse task_id (optional)
if taskIDStr := c.PostForm("task_id"); taskIDStr != "" {
if taskIDStr := c.FormValue("task_id"); taskIDStr != "" {
if taskID, err := strconv.ParseUint(taskIDStr, 10, 32); err == nil {
tid := uint(taskID)
req.TaskID = &tid
@@ -135,14 +119,14 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
// Parse purchase_price (optional)
if priceStr := c.PostForm("purchase_price"); priceStr != "" {
if priceStr := c.FormValue("purchase_price"); priceStr != "" {
if price, err := decimal.NewFromString(priceStr); err == nil {
req.PurchasePrice = &price
}
}
// Parse purchase_date (optional)
if dateStr := c.PostForm("purchase_date"); dateStr != "" {
if dateStr := c.FormValue("purchase_date"); dateStr != "" {
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
req.PurchaseDate = &t
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
@@ -151,7 +135,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
// Parse expiry_date (optional)
if dateStr := c.PostForm("expiry_date"); dateStr != "" {
if dateStr := c.FormValue("expiry_date"); dateStr != "" {
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
req.ExpiryDate = &t
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
@@ -171,8 +155,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
if uploadedFile != nil && h.storageService != nil {
result, err := h.storageService.Upload(uploadedFile, "documents")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_file")})
return
return apperrors.BadRequest("error.failed_to_upload_file")
}
req.FileURL = result.URL
req.FileName = result.FileName
@@ -182,122 +165,79 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
} else {
// Standard JSON request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
}
response, err := h.documentService.CreateDocument(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateDocument handles PUT/PATCH /api/documents/:id/
func (h *DocumentHandler) UpdateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) UpdateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
var req requests.UpdateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.documentService.UpdateDocument(uint(documentID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteDocument handles DELETE /api/documents/:id/
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) DeleteDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
err = h.documentService.DeleteDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deleted successfully"})
}
// ActivateDocument handles POST /api/documents/:id/activate/
func (h *DocumentHandler) ActivateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ActivateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.ActivateDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_activated"), "document": response})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document activated successfully", "document": response})
}
// DeactivateDocument handles POST /api/documents/:id/deactivate/
func (h *DocumentHandler) DeactivateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) DeactivateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.DeactivateDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deactivated"), "document": response})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response})
}

View File

@@ -1,13 +1,13 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
@@ -39,132 +39,117 @@ func NewMediaHandler(
// ServeDocument serves a document file with access control
// GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get document
doc, err := h.documentRepo.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(doc.FileURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
// Set caching headers (private, 1 hour)
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeDocumentImage serves a document image with access control
// GET /api/media/document-image/:id
func (h *MediaHandler) ServeDocumentImage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get document image
img, err := h.documentRepo.FindImageByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
return
return apperrors.NotFound("error.image_not_found")
}
// Get parent document to check residence access
doc, err := h.documentRepo.FindByID(img.DocumentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parent document not found"})
return
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeCompletionImage serves a task completion image with access control
// GET /api/media/completion-image/:id
func (h *MediaHandler) ServeCompletionImage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get completion image
img, err := h.taskRepo.FindCompletionImageByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
return
return apperrors.NotFound("error.image_not_found")
}
// Get the completion to get the task
completion, err := h.taskRepo.FindCompletionByID(img.CompletionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
return apperrors.NotFound("error.completion_not_found")
}
// Get task to check residence access
task, err := h.taskRepo.FindByID(completion.TaskID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
return apperrors.NotFound("error.task_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// resolveFilePath converts a stored URL to an actual file path

View File

@@ -1,13 +1,12 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -24,17 +23,17 @@ func NewNotificationHandler(notificationService *services.NotificationService) *
}
// ListNotifications handles GET /api/notifications/
func (h *NotificationHandler) ListNotifications(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) ListNotifications(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if o := c.QueryParam("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
@@ -42,157 +41,132 @@ func (h *NotificationHandler) ListNotifications(c *gin.Context) {
notifications, err := h.notificationService.GetNotifications(user.ID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(notifications),
"results": notifications,
})
}
// GetUnreadCount handles GET /api/notifications/unread-count/
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) GetUnreadCount(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
count, err := h.notificationService.GetUnreadCount(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"unread_count": count})
return c.JSON(http.StatusOK, map[string]interface{}{"unread_count": count})
}
// MarkAsRead handles POST /api/notifications/:id/read/
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) MarkAsRead(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
notificationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_notification_id")})
return
return apperrors.BadRequest("error.invalid_notification_id")
}
err = h.notificationService.MarkAsRead(uint(notificationID), user.ID)
if err != nil {
if errors.Is(err, services.ErrNotificationNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.notification_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.notification_marked_read")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.notification_marked_read"})
}
// MarkAllAsRead handles POST /api/notifications/mark-all-read/
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) MarkAllAsRead(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
err := h.notificationService.MarkAllAsRead(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.all_notifications_marked_read")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.all_notifications_marked_read"})
}
// GetPreferences handles GET /api/notifications/preferences/
func (h *NotificationHandler) GetPreferences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) GetPreferences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
prefs, err := h.notificationService.GetPreferences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, prefs)
return c.JSON(http.StatusOK, prefs)
}
// UpdatePreferences handles PUT/PATCH /api/notifications/preferences/
func (h *NotificationHandler) UpdatePreferences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) UpdatePreferences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.UpdatePreferencesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
prefs, err := h.notificationService.UpdatePreferences(user.ID, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, prefs)
return c.JSON(http.StatusOK, prefs)
}
// RegisterDevice handles POST /api/notifications/devices/
func (h *NotificationHandler) RegisterDevice(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) RegisterDevice(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.RegisterDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
device, err := h.notificationService.RegisterDevice(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrInvalidPlatform) {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, device)
return c.JSON(http.StatusCreated, device)
}
// ListDevices handles GET /api/notifications/devices/
func (h *NotificationHandler) ListDevices(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) ListDevices(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
devices, err := h.notificationService.ListDevices(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, devices)
return c.JSON(http.StatusOK, devices)
}
// DeleteDevice handles DELETE /api/notifications/devices/:id/
func (h *NotificationHandler) DeleteDevice(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
deviceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_device_id")})
return
return apperrors.BadRequest("error.invalid_device_id")
}
platform := c.Query("platform")
platform := c.QueryParam("platform")
if platform == "" {
platform = "ios" // Default to iOS
}
err = h.notificationService.DeleteDevice(uint(deviceID), platform, user.ID)
if err != nil {
if errors.Is(err, services.ErrInvalidPlatform) {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.device_removed")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_removed"})
}

View File

@@ -1,17 +1,18 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/validator"
)
// ResidenceHandler handles residence-related HTTP requests
@@ -31,343 +32,252 @@ func NewResidenceHandler(residenceService *services.ResidenceService, pdfService
}
// ListResidences handles GET /api/residences/
func (h *ResidenceHandler) ListResidences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) ListResidences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.residenceService.ListResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetMyResidences handles GET /api/residences/my-residences/
func (h *ResidenceHandler) GetMyResidences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetMyResidences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
response, err := h.residenceService.GetMyResidences(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetSummary handles GET /api/residences/summary/
// Returns just the task statistics summary without full residence data
func (h *ResidenceHandler) GetSummary(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetSummary(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
summary, err := h.residenceService.GetSummary(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, summary)
return c.JSON(http.StatusOK, summary)
}
// GetResidence handles GET /api/residences/:id/
func (h *ResidenceHandler) GetResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.residenceService.GetResidence(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateResidence handles POST /api/residences/
func (h *ResidenceHandler) CreateResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) CreateResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.residenceService.CreateResidence(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrPropertiesLimitReached) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.properties_limit_reached")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateResidence handles PUT/PATCH /api/residences/:id/
func (h *ResidenceHandler) UpdateResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) UpdateResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.UpdateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.residenceService.UpdateResidence(uint(residenceID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteResidence handles DELETE /api/residences/:id/
func (h *ResidenceHandler) DeleteResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) DeleteResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.residenceService.DeleteResidence(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GenerateShareCode handles POST /api/residences/:id/generate-share-code/
func (h *ResidenceHandler) GenerateShareCode(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateShareCode(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.GenerateShareCodeRequest
// Request body is optional
c.ShouldBindJSON(&req)
c.Bind(&req)
response, err := h.residenceService.GenerateShareCode(uint(residenceID), user.ID, req.ExpiresInHours)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GenerateSharePackage handles POST /api/residences/:id/generate-share-package/
// Returns a share code with metadata for creating a .casera package file
func (h *ResidenceHandler) GenerateSharePackage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateSharePackage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.GenerateShareCodeRequest
// Request body is optional (for expires_in_hours)
c.ShouldBindJSON(&req)
c.Bind(&req)
response, err := h.residenceService.GenerateSharePackage(uint(residenceID), user.ID, req.ExpiresInHours)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// JoinWithCode handles POST /api/residences/join-with-code/
func (h *ResidenceHandler) JoinWithCode(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) JoinWithCode(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.JoinWithCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.residenceService.JoinWithCode(req.Code, user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrShareCodeInvalid):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_invalid")})
case errors.Is(err, services.ErrShareCodeExpired):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_expired")})
case errors.Is(err, services.ErrUserAlreadyMember):
c.JSON(http.StatusConflict, gin.H{"error": i18n.LocalizedMessage(c, "error.user_already_member")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetResidenceUsers handles GET /api/residences/:id/users/
func (h *ResidenceHandler) GetResidenceUsers(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetResidenceUsers(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
users, err := h.residenceService.GetResidenceUsers(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, users)
return c.JSON(http.StatusOK, users)
}
// RemoveResidenceUser handles DELETE /api/residences/:id/users/:user_id/
func (h *ResidenceHandler) RemoveResidenceUser(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) RemoveResidenceUser(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
userIDToRemove, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")})
return
return apperrors.BadRequest("error.invalid_user_id")
}
err = h.residenceService.RemoveUser(uint(residenceID), uint(userIDToRemove), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
case errors.Is(err, services.ErrCannotRemoveOwner):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.cannot_remove_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.user_removed")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": i18n.LocalizedMessage(c, "message.user_removed")})
}
// GetResidenceTypes handles GET /api/residences/types/
func (h *ResidenceHandler) GetResidenceTypes(c *gin.Context) {
func (h *ResidenceHandler) GetResidenceTypes(c echo.Context) error {
types, err := h.residenceService.GetResidenceTypes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, types)
return c.JSON(http.StatusOK, types)
}
// GenerateTasksReport handles POST /api/residences/:id/generate-tasks-report/
// Generates a PDF report of tasks for the residence and emails it
func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateTasksReport(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
// Optional request body for email recipient
var req struct {
Email string `json:"email"`
}
c.ShouldBindJSON(&req)
c.Bind(&req)
// Generate the report data
report, err := h.residenceService.GenerateTasksReport(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
// Determine recipient email
@@ -415,7 +325,7 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) {
message = i18n.LocalizedMessage(c, "message.tasks_report_email_failed")
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": message,
"residence_name": report.ResidenceName,
"recipient_email": recipientEmail,

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -19,22 +19,22 @@ import (
"gorm.io/gorm"
)
func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *gin.Engine, *gorm.DB) {
func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
handler := NewResidenceHandler(residenceService, nil, nil)
router := testutil.SetupTestRouter()
return handler, router, db
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestResidenceHandler_CreateResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
@@ -47,7 +47,7 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
PostalCode: "78701",
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -88,7 +88,7 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
IsPrimary: &isPrimary,
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -114,28 +114,28 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
// Missing name - this is required
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetResidence)
otherAuthGroup := router.Group("/api/other-residences")
otherAuthGroup := e.Group("/api/other-residences")
otherAuthGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherAuthGroup.GET("/:id/", handler.GetResidence)
t.Run("get own residence", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -148,37 +148,37 @@ func TestResidenceHandler_GetResidence(t *testing.T) {
})
t.Run("get residence with invalid ID", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/invalid/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("get non-existent residence", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/9999/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/9999/", nil, "test-token")
// Returns 403 (access denied) rather than 404 to not reveal whether an ID exists
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_ListResidences(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListResidences)
t.Run("list residences", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -191,7 +191,7 @@ func TestResidenceHandler_ListResidences(t *testing.T) {
}
func TestResidenceHandler_UpdateResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name")
@@ -200,11 +200,11 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateResidence)
sharedGroup := router.Group("/api/shared-residences")
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.PUT("/:id/", handler.UpdateResidence)
@@ -216,7 +216,7 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
City: &newCity,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -239,14 +239,14 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
Name: &newName,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_DeleteResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "To Delete")
@@ -254,22 +254,22 @@ func TestResidenceHandler_DeleteResidence(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteResidence)
sharedGroup := router.Group("/api/shared-residences")
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.DELETE("/:id/", handler.DeleteResidence)
t.Run("shared user cannot delete", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("owner can delete", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -285,11 +285,11 @@ func TestResidenceHandler_DeleteResidence(t *testing.T) {
}
func TestResidenceHandler_GenerateShareCode(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Share Test")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode)
@@ -298,7 +298,7 @@ func TestResidenceHandler_GenerateShareCode(t *testing.T) {
ExpiresInHours: 24,
}
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -314,7 +314,7 @@ func TestResidenceHandler_GenerateShareCode(t *testing.T) {
}
func TestResidenceHandler_JoinWithCode(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Join Test")
@@ -326,11 +326,11 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
shareResp, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(newUser))
authGroup.POST("/join-with-code/", handler.JoinWithCode)
ownerGroup := router.Group("/api/owner-residences")
ownerGroup := e.Group("/api/owner-residences")
ownerGroup.Use(testutil.MockAuthMiddleware(owner))
ownerGroup.POST("/join-with-code/", handler.JoinWithCode)
@@ -339,7 +339,7 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: shareResp.ShareCode.Code,
}
w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -363,7 +363,7 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: shareResp2.ShareCode.Code,
}
w := testutil.MakeRequest(router, "POST", "/api/owner-residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/owner-residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusConflict)
})
@@ -373,14 +373,14 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: "ABCDEF", // Valid length (6) but non-existent code
}
w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Users Test")
@@ -388,12 +388,12 @@ func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.GET("/:id/users/", handler.GetResidenceUsers)
t.Run("get residence users", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -406,7 +406,7 @@ func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
}
func TestResidenceHandler_RemoveUser(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Remove Test")
@@ -414,12 +414,12 @@ func TestResidenceHandler_RemoveUser(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser)
t.Run("remove shared user", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -428,23 +428,23 @@ func TestResidenceHandler_RemoveUser(t *testing.T) {
})
t.Run("cannot remove owner", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidenceTypes(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/types/", handler.GetResidenceTypes)
t.Run("get residence types", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/types/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/types/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -457,10 +457,10 @@ func TestResidenceHandler_GetResidenceTypes(t *testing.T) {
}
func TestResidenceHandler_JSONResponses(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
authGroup.GET("/", handler.ListResidences)
@@ -474,7 +474,7 @@ func TestResidenceHandler_JSONResponses(t *testing.T) {
PostalCode: "78701",
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -513,7 +513,7 @@ func TestResidenceHandler_JSONResponses(t *testing.T) {
})
t.Run("list response returns array", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
@@ -51,20 +51,19 @@ func NewStaticDataHandler(
// GetStaticData handles GET /api/static_data/
// Returns all lookup/reference data in a single response with ETag support
func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
ctx := c.Request.Context()
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context()
// Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
clientETag := strings.TrimPrefix(c.GetHeader("If-None-Match"), "W/")
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx)
if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified
c.Status(http.StatusNotModified)
return
return c.NoContent(http.StatusNotModified)
}
}
@@ -76,11 +75,10 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
// Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx)
if etagErr == nil {
c.Header("ETag", etag)
c.Header("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
c.JSON(http.StatusOK, cachedData)
return
return c.JSON(http.StatusOK, cachedData)
} else if err != redis.Nil {
// Log cache error but continue to fetch from DB
log.Warn().Err(err).Msg("Failed to get cached seeded data")
@@ -90,38 +88,32 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
// Cache miss - fetch all data from services
residenceTypes, err := h.residenceService.GetResidenceTypes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_residence_types")})
return
return err
}
taskCategories, err := h.taskService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_categories")})
return
return err
}
taskPriorities, err := h.taskService.GetPriorities()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_priorities")})
return
return err
}
taskFrequencies, err := h.taskService.GetFrequencies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_frequencies")})
return
return err
}
contractorSpecialties, err := h.contractorService.GetSpecialties()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_contractor_specialties")})
return
return err
}
taskTemplates, err := h.taskTemplateService.GetGrouped()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_templates")})
return
return err
}
// Build response
@@ -140,19 +132,19 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else {
c.Header("ETag", etag)
c.Header("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
}
c.JSON(http.StatusOK, seededData)
return c.JSON(http.StatusOK, seededData)
}
// RefreshStaticData handles POST /api/static_data/refresh/
// This is a no-op since data is fetched fresh each time
// Kept for API compatibility with mobile clients
func (h *StaticDataHandler) RefreshStaticData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
func (h *StaticDataHandler) RefreshStaticData(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": i18n.LocalizedMessage(c, "message.static_data_refreshed"),
"status": "success",
})

View File

@@ -1,12 +1,11 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -23,91 +22,80 @@ func NewSubscriptionHandler(subscriptionService *services.SubscriptionService) *
}
// GetSubscription handles GET /api/subscription/
func (h *SubscriptionHandler) GetSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
subscription, err := h.subscriptionService.GetSubscription(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, subscription)
return c.JSON(http.StatusOK, subscription)
}
// GetSubscriptionStatus handles GET /api/subscription/status/
func (h *SubscriptionHandler) GetSubscriptionStatus(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetSubscriptionStatus(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
status, err := h.subscriptionService.GetSubscriptionStatus(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, status)
return c.JSON(http.StatusOK, status)
}
// GetUpgradeTrigger handles GET /api/subscription/upgrade-trigger/:key/
func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
func (h *SubscriptionHandler) GetUpgradeTrigger(c echo.Context) error {
key := c.Param("key")
trigger, err := h.subscriptionService.GetUpgradeTrigger(key)
if err != nil {
if errors.Is(err, services.ErrUpgradeTriggerNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.upgrade_trigger_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, trigger)
return c.JSON(http.StatusOK, trigger)
}
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c *gin.Context) {
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c echo.Context) error {
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, triggers)
return c.JSON(http.StatusOK, triggers)
}
// GetFeatureBenefits handles GET /api/subscription/features/
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
func (h *SubscriptionHandler) GetFeatureBenefits(c echo.Context) error {
benefits, err := h.subscriptionService.GetFeatureBenefits()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, benefits)
return c.JSON(http.StatusOK, benefits)
}
// GetPromotions handles GET /api/subscription/promotions/
func (h *SubscriptionHandler) GetPromotions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetPromotions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
promotions, err := h.subscriptionService.GetActivePromotions(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, promotions)
return c.JSON(http.StatusOK, promotions)
}
// ProcessPurchase handles POST /api/subscription/purchase/
func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.ProcessPurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
var subscription *services.SubscriptionResponse
@@ -117,53 +105,48 @@ func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) {
case "ios":
// StoreKit 2 uses transaction_id, StoreKit 1 uses receipt_data
if req.TransactionID == "" && req.ReceiptData == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.receipt_data_required")})
return
return apperrors.BadRequest("error.receipt_data_required")
}
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
case "android":
if req.PurchaseToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.purchase_token_required")})
return
return apperrors.BadRequest("error.purchase_token_required")
}
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_upgraded"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_upgraded",
"subscription": subscription,
})
}
// CancelSubscription handles POST /api/subscription/cancel/
func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) CancelSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
subscription, err := h.subscriptionService.CancelSubscription(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_cancelled"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_cancelled",
"subscription": subscription,
})
}
// RestoreSubscription handles POST /api/subscription/restore/
func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.ProcessPurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
// Same logic as ProcessPurchase - validates receipt/token and restores
@@ -178,12 +161,11 @@ func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) {
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_restored"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_restored",
"subscription": subscription,
})
}

View File

@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/treytartt/casera-api/internal/config"
@@ -93,27 +93,24 @@ type AppleRenewalInfo struct {
}
// HandleAppleWebhook handles POST /api/subscription/webhook/apple/
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Apple Webhook: Failed to read body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var payload AppleNotificationPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("Apple Webhook: Failed to parse payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid payload"})
}
// Decode and verify the signed payload (JWS)
notification, err := h.decodeAppleSignedPayload(payload.SignedPayload)
if err != nil {
log.Printf("Apple Webhook: Failed to decode signed payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signed payload"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signed payload"})
}
log.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s",
@@ -125,8 +122,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
if notification.Data.BundleID != cfg.AppleIAP.BundleID {
log.Printf("Apple Webhook: Bundle ID mismatch: got %s, expected %s",
notification.Data.BundleID, cfg.AppleIAP.BundleID)
c.JSON(http.StatusBadRequest, gin.H{"error": "bundle ID mismatch"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "bundle ID mismatch"})
}
}
@@ -134,8 +130,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo)
if err != nil {
log.Printf("Apple Webhook: Failed to decode transaction: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid transaction info"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid transaction info"})
}
// Decode renewal info if present
@@ -151,7 +146,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
}
// Always return 200 OK to acknowledge receipt
c.JSON(http.StatusOK, gin.H{"status": "received"})
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// decodeAppleSignedPayload decodes and verifies an Apple JWS payload
@@ -454,41 +449,36 @@ const (
)
// HandleGoogleWebhook handles POST /api/subscription/webhook/google/
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Google Webhook: Failed to read body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var notification GoogleNotification
if err := json.Unmarshal(body, &notification); err != nil {
log.Printf("Google Webhook: Failed to parse notification: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid notification"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid notification"})
}
// Decode the base64 data
data, err := base64.StdEncoding.DecodeString(notification.Message.Data)
if err != nil {
log.Printf("Google Webhook: Failed to decode message data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message data"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid message data"})
}
var devNotification GoogleDeveloperNotification
if err := json.Unmarshal(data, &devNotification); err != nil {
log.Printf("Google Webhook: Failed to parse developer notification: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid developer notification"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"})
}
// Handle test notification
if devNotification.TestNotification != nil {
log.Printf("Google Webhook: Received test notification")
c.JSON(http.StatusOK, gin.H{"status": "test received"})
return
return c.JSON(http.StatusOK, map[string]interface{}{"status": "test received"})
}
// Verify package name
@@ -497,8 +487,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
if devNotification.PackageName != cfg.GoogleIAP.PackageName {
log.Printf("Google Webhook: Package name mismatch: got %s, expected %s",
devNotification.PackageName, cfg.GoogleIAP.PackageName)
c.JSON(http.StatusBadRequest, gin.H{"error": "package name mismatch"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "package name mismatch"})
}
}
@@ -511,7 +500,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
}
// Acknowledge the message
c.JSON(http.StatusOK, gin.H{"status": "received"})
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// processGoogleSubscriptionNotification handles Google subscription events
@@ -736,7 +725,7 @@ func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string)
}
// VerifyGooglePubSubToken verifies the Pub/Sub push token (if configured)
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c *gin.Context) bool {
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool {
// If you configured a push endpoint with authentication, verify here
// The token is typically in the Authorization header

View File

@@ -1,18 +1,17 @@
package handlers
import (
"errors"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -33,55 +32,44 @@ func NewTaskHandler(taskService *services.TaskService, storageService *services.
}
// ListTasks handles GET /api/tasks/
func (h *TaskHandler) ListTasks(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ListTasks(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
response, err := h.taskService.ListTasks(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetTask handles GET /api/tasks/:id/
func (h *TaskHandler) GetTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.GetTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/
func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTasksByResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
daysThreshold := 30
if d := c.Query("days_threshold"); d != "" {
if d := c.QueryParam("days_threshold"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
daysThreshold = parsed
}
@@ -89,352 +77,241 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
response, err := h.taskService.GetTasksByResidence(uint(residenceID), user.ID, daysThreshold, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateTask handles POST /api/tasks/
func (h *TaskHandler) CreateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CreateTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.taskService.CreateTask(&req, user.ID, userNow)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateTask handles PUT/PATCH /api/tasks/:id/
func (h *TaskHandler) UpdateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UpdateTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
var req requests.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.taskService.UpdateTask(uint(taskID), user.ID, &req, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteTask handles DELETE /api/tasks/:id/
func (h *TaskHandler) DeleteTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) DeleteTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.DeleteTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// MarkInProgress handles POST /api/tasks/:id/mark-in-progress/
func (h *TaskHandler) MarkInProgress(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) MarkInProgress(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.MarkInProgress(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CancelTask handles POST /api/tasks/:id/cancel/
func (h *TaskHandler) CancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CancelTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.CancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyCancelled):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_cancelled")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UncancelTask handles POST /api/tasks/:id/uncancel/
func (h *TaskHandler) UncancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UncancelTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.UncancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ArchiveTask handles POST /api/tasks/:id/archive/
func (h *TaskHandler) ArchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ArchiveTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.ArchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyArchived):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_archived")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UnarchiveTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// QuickComplete handles POST /api/tasks/:id/quick-complete/
// Lightweight endpoint for widget - just returns 200 OK on success
func (h *TaskHandler) QuickComplete(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) QuickComplete(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
err = h.taskService.QuickComplete(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.Status(http.StatusOK)
return c.NoContent(http.StatusOK)
}
// === Task Completions ===
// GetTaskCompletions handles GET /api/tasks/:id/completions/
func (h *TaskHandler) GetTaskCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTaskCompletions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.GetCompletionsByTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListCompletions handles GET /api/task-completions/
func (h *TaskHandler) ListCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ListCompletions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.taskService.ListCompletions(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetCompletion handles GET /api/task-completions/:id/
func (h *TaskHandler) GetCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
return apperrors.BadRequest("error.invalid_completion_id")
}
response, err := h.taskService.GetCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateCompletion handles POST /api/task-completions/
// Supports both JSON and multipart form data (for image uploads)
func (h *TaskHandler) CreateCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CreateCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskCompletionRequest
contentType := c.GetHeader("Content-Type")
contentType := c.Request().Header.Get("Content-Type")
// Check if this is a multipart form request (image upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")})
return
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse task_id (required)
taskIDStr := c.PostForm("task_id")
taskIDStr := c.FormValue("task_id")
if taskIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_id_required")})
return
return apperrors.BadRequest("error.task_id_required")
}
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id_value")})
return
return apperrors.BadRequest("error.invalid_task_id_value")
}
req.TaskID = uint(taskID)
// Parse notes (optional)
req.Notes = c.PostForm("notes")
req.Notes = c.FormValue("notes")
// Parse actual_cost (optional)
if costStr := c.PostForm("actual_cost"); costStr != "" {
if costStr := c.FormValue("actual_cost"); costStr != "" {
cost, err := decimal.NewFromString(costStr)
if err == nil {
req.ActualCost = &cost
@@ -442,7 +319,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
}
// Parse completed_at (optional)
if completedAtStr := c.PostForm("completed_at"); completedAtStr != "" {
if completedAtStr := c.FormValue("completed_at"); completedAtStr != "" {
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
req.CompletedAt = &t
}
@@ -462,87 +339,65 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
if h.storageService != nil {
result, err := h.storageService.Upload(file, "completions")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_image")})
return
return apperrors.BadRequest("error.failed_to_upload_image")
}
req.ImageURLs = append(req.ImageURLs, result.URL)
}
}
} else {
// Standard JSON request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
}
response, err := h.taskService.CreateCompletion(&req, user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// DeleteCompletion handles DELETE /api/task-completions/:id/
func (h *TaskHandler) DeleteCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) DeleteCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
return apperrors.BadRequest("error.invalid_completion_id")
}
response, err := h.taskService.DeleteCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// === Lookups ===
// GetCategories handles GET /api/tasks/categories/
func (h *TaskHandler) GetCategories(c *gin.Context) {
func (h *TaskHandler) GetCategories(c echo.Context) error {
categories, err := h.taskService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, categories)
return c.JSON(http.StatusOK, categories)
}
// GetPriorities handles GET /api/tasks/priorities/
func (h *TaskHandler) GetPriorities(c *gin.Context) {
func (h *TaskHandler) GetPriorities(c echo.Context) error {
priorities, err := h.taskService.GetPriorities()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, priorities)
return c.JSON(http.StatusOK, priorities)
}
// GetFrequencies handles GET /api/tasks/frequencies/
func (h *TaskHandler) GetFrequencies(c *gin.Context) {
func (h *TaskHandler) GetFrequencies(c echo.Context) error {
frequencies, err := h.taskService.GetFrequencies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, frequencies)
return c.JSON(http.StatusOK, frequencies)
}

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -20,23 +20,23 @@ import (
"gorm.io/gorm"
)
func setupTaskHandler(t *testing.T) (*TaskHandler, *gin.Engine, *gorm.DB) {
func setupTaskHandler(t *testing.T) (*TaskHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskService := services.NewTaskService(taskRepo, residenceRepo)
handler := NewTaskHandler(taskService, nil)
router := testutil.SetupTestRouter()
return handler, router, db
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestTaskHandler_CreateTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
@@ -47,7 +47,7 @@ func TestTaskHandler_CreateTask(t *testing.T) {
Description: "Kitchen faucet is dripping",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -86,7 +86,7 @@ func TestTaskHandler_CreateTask(t *testing.T) {
EstimatedCost: &estimatedCost,
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -116,29 +116,29 @@ func TestTaskHandler_CreateTask(t *testing.T) {
Title: "Unauthorized Task",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_GetTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetTask)
otherGroup := router.Group("/api/other-tasks")
otherGroup := e.Group("/api/other-tasks")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.GET("/:id/", handler.GetTask)
t.Run("get own task", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -151,32 +151,32 @@ func TestTaskHandler_GetTask(t *testing.T) {
})
t.Run("get non-existent task", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/9999/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/9999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_ListTasks(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListTasks)
t.Run("list tasks", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -201,7 +201,7 @@ func TestTaskHandler_ListTasks(t *testing.T) {
}
func TestTaskHandler_GetTasksByResidence(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
@@ -209,12 +209,12 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
// Create tasks with different states
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Active Task")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.GetTasksByResidence)
t.Run("get kanban columns", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -231,7 +231,7 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
})
t.Run("kanban column structure", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -254,12 +254,12 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
}
func TestTaskHandler_UpdateTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateTask)
@@ -271,7 +271,7 @@ func TestTaskHandler_UpdateTask(t *testing.T) {
Description: &newDesc,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -290,17 +290,17 @@ func TestTaskHandler_UpdateTask(t *testing.T) {
}
func TestTaskHandler_DeleteTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Delete")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteTask)
t.Run("delete task", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -316,17 +316,17 @@ func TestTaskHandler_DeleteTask(t *testing.T) {
}
func TestTaskHandler_CancelTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Cancel")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/cancel/", handler.CancelTask)
t.Run("cancel task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -344,14 +344,14 @@ func TestTaskHandler_CancelTask(t *testing.T) {
t.Run("cancel already cancelled task", func(t *testing.T) {
// Already cancelled from previous test
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_UncancelTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Uncancel")
@@ -360,12 +360,12 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Cancel(task.ID)
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/uncancel/", handler.UncancelTask)
t.Run("uncancel task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -383,17 +383,17 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
}
func TestTaskHandler_ArchiveTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Archive")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/archive/", handler.ArchiveTask)
t.Run("archive task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -411,7 +411,7 @@ func TestTaskHandler_ArchiveTask(t *testing.T) {
}
func TestTaskHandler_UnarchiveTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Unarchive")
@@ -420,12 +420,12 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Archive(task.ID)
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/unarchive/", handler.UnarchiveTask)
t.Run("unarchive task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -443,18 +443,18 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
}
func TestTaskHandler_MarkInProgress(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Start")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/mark-in-progress/", handler.MarkInProgress)
t.Run("mark in progress", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -470,12 +470,12 @@ func TestTaskHandler_MarkInProgress(t *testing.T) {
}
func TestTaskHandler_CreateCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Complete")
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateCompletion)
@@ -487,7 +487,7 @@ func TestTaskHandler_CreateCompletion(t *testing.T) {
Notes: "Completed successfully",
}
w := testutil.MakeRequest(router, "POST", "/api/task-completions/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -507,7 +507,7 @@ func TestTaskHandler_CreateCompletion(t *testing.T) {
}
func TestTaskHandler_ListCompletions(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -521,12 +521,12 @@ func TestTaskHandler_ListCompletions(t *testing.T) {
})
}
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListCompletions)
t.Run("list completions", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/task-completions/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/task-completions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -539,7 +539,7 @@ func TestTaskHandler_ListCompletions(t *testing.T) {
}
func TestTaskHandler_GetCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -552,12 +552,12 @@ func TestTaskHandler_GetCompletion(t *testing.T) {
}
db.Create(completion)
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetCompletion)
t.Run("get completion", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -571,7 +571,7 @@ func TestTaskHandler_GetCompletion(t *testing.T) {
}
func TestTaskHandler_DeleteCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -583,12 +583,12 @@ func TestTaskHandler_DeleteCompletion(t *testing.T) {
}
db.Create(completion)
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteCompletion)
t.Run("delete completion", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -604,18 +604,18 @@ func TestTaskHandler_DeleteCompletion(t *testing.T) {
}
func TestTaskHandler_GetLookups(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/categories/", handler.GetCategories)
authGroup.GET("/priorities/", handler.GetPriorities)
authGroup.GET("/frequencies/", handler.GetFrequencies)
t.Run("get categories", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/categories/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/categories/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -629,7 +629,7 @@ func TestTaskHandler_GetLookups(t *testing.T) {
})
t.Run("get priorities", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/priorities/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/priorities/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -644,7 +644,7 @@ func TestTaskHandler_GetLookups(t *testing.T) {
})
t.Run("get frequencies", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/frequencies/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/frequencies/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -657,12 +657,12 @@ func TestTaskHandler_GetLookups(t *testing.T) {
}
func TestTaskHandler_JSONResponses(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
authGroup.GET("/", handler.ListTasks)
@@ -674,7 +674,7 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
Description: "Testing JSON structure",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -714,7 +714,7 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
})
t.Run("list response returns kanban board", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/services"
)
@@ -24,83 +24,74 @@ func NewTaskTemplateHandler(templateService *services.TaskTemplateService) *Task
// GetTemplates handles GET /api/tasks/templates/
// Returns all active task templates as a flat list
func (h *TaskTemplateHandler) GetTemplates(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplates(c echo.Context) error {
templates, err := h.templateService.GetAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplatesGrouped handles GET /api/tasks/templates/grouped/
// Returns all templates grouped by category
func (h *TaskTemplateHandler) GetTemplatesGrouped(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplatesGrouped(c echo.Context) error {
grouped, err := h.templateService.GetGrouped()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, grouped)
return c.JSON(http.StatusOK, grouped)
}
// SearchTemplates handles GET /api/tasks/templates/search/
// Searches templates by query string
func (h *TaskTemplateHandler) SearchTemplates(c *gin.Context) {
query := c.Query("q")
func (h *TaskTemplateHandler) SearchTemplates(c echo.Context) error {
query := c.QueryParam("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
return
return apperrors.BadRequest("error.query_required")
}
if len(query) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query must be at least 2 characters"})
return
return apperrors.BadRequest("error.query_too_short")
}
templates, err := h.templateService.Search(query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_search_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplatesByCategory handles GET /api/tasks/templates/by-category/:category_id/
// Returns templates for a specific category
func (h *TaskTemplateHandler) GetTemplatesByCategory(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplatesByCategory(c echo.Context) error {
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
templates, err := h.templateService.GetByCategory(uint(categoryID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplate handles GET /api/tasks/templates/:id/
// Returns a single template by ID
func (h *TaskTemplateHandler) GetTemplate(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplate(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
template, err := h.templateService.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.template_not_found")})
return
return err
}
c.JSON(http.StatusOK, template)
return c.JSON(http.StatusOK, template)
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/services"
)
@@ -26,7 +26,7 @@ var transparentGIF, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP/
// TrackEmailOpen handles email open tracking via tracking pixel
// GET /api/track/open/:trackingID
func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) {
func (h *TrackingHandler) TrackEmailOpen(c echo.Context) error {
trackingID := c.Param("trackingID")
if trackingID != "" && h.onboardingService != nil {
@@ -37,9 +37,9 @@ func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) {
}
// Return 1x1 transparent GIF
c.Header("Content-Type", "image/gif")
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, "image/gif", transparentGIF)
c.Response().Header().Set("Content-Type", "image/gif")
c.Response().Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return c.Blob(http.StatusOK, "image/gif", transparentGIF)
}

View File

@@ -3,9 +3,9 @@ package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/services"
)
@@ -21,77 +21,72 @@ func NewUploadHandler(storageService *services.StorageService) *UploadHandler {
// UploadImage handles POST /api/uploads/image
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadImage(c *gin.Context) {
func (h *UploadHandler) UploadImage(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
// Get category from query param (default: images)
category := c.DefaultQuery("category", "images")
category := c.QueryParam("category")
if category == "" {
category = "images"
}
result, err := h.storageService.Upload(file, category)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// UploadDocument handles POST /api/uploads/document
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadDocument(c *gin.Context) {
func (h *UploadHandler) UploadDocument(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(file, "documents")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// UploadCompletion handles POST /api/uploads/completion
// For task completion photos
func (h *UploadHandler) UploadCompletion(c *gin.Context) {
func (h *UploadHandler) UploadCompletion(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(file, "completions")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// DeleteFile handles DELETE /api/uploads
// Expects JSON body with "url" field
func (h *UploadHandler) DeleteFile(c *gin.Context) {
func (h *UploadHandler) DeleteFile(c echo.Context) error {
var req struct {
URL string `json:"url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := h.storageService.Delete(req.URL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.file_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
}

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -25,58 +25,50 @@ func NewUserHandler(userService *services.UserService) *UserHandler {
}
// ListUsers handles GET /api/users/
func (h *UserHandler) ListUsers(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) ListUsers(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
// Only allow listing users that share residences with the current user
users, err := h.userService.ListUsersInSharedResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(users),
"results": users,
})
}
// GetUser handles GET /api/users/:id/
func (h *UserHandler) GetUser(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) GetUser(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")})
return
return apperrors.BadRequest("error.invalid_user_id")
}
// Can only view users that share a residence
targetUser, err := h.userService.GetUserIfSharedResidence(uint(userID), user.ID)
if err != nil {
if err == services.ErrUserNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.user_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, targetUser)
return c.JSON(http.StatusOK, targetUser)
}
// ListProfiles handles GET /api/users/profiles/
func (h *UserHandler) ListProfiles(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) ListProfiles(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
// List profiles of users in shared residences
profiles, err := h.userService.ListProfilesInSharedResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(profiles),
"results": profiles,
})