Remove timezone from User model and pass as request parameter

Timezone is now sent from the client on each request that needs it,
instead of storing it in the database. This simplifies the model and
allows timezone to be dynamic (useful for traveling users).

Changes:
- Remove timezone field from User entity
- Remove timezone from user registration
- GET /habits/today now requires ?timezone= query param
- Add migration to drop timezone column from database
- Update related tests
This commit is contained in:
2025-11-29 16:18:04 +01:00
parent a2aa8b2a76
commit 5d92820591
21 changed files with 75 additions and 153 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ func main() {
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator) authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo, translator) habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator) userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
@@ -48,7 +48,7 @@ func TestDeleteUserHandler_Success(t *testing.T) {
repo := &mockDeleteUserRepo{ repo := &mockDeleteUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = id user.ID = id
return user, nil return user, nil
}, },
@@ -112,7 +112,7 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) {
repo := &mockDeleteUserRepo{ repo := &mockDeleteUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = id user.ID = id
return user, nil return user, nil
}, },
@@ -17,7 +17,6 @@ import (
type RegisterUserCommand struct { type RegisterUserCommand struct {
Email string Email string
Password string Password string
Timezone string
} }
type RegisterUserResult struct { type RegisterUserResult struct {
@@ -57,7 +56,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
return nil, errors.ErrRegistrationClosed return nil, errors.ErrRegistrationClosed
} }
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil { if err := validation.ValidateRegistration(cmd.Email, cmd.Password); err != nil {
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err) return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
} }
@@ -71,7 +70,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
return nil, err return nil, err
} }
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone) user := entities.NewUser(cmd.Email, hashedPassword)
emailVerificationRequired := false emailVerificationRequired := false
if h.emailService != nil { if h.emailService != nil {
@@ -74,7 +74,6 @@ func TestRegisterUserHandler_Success(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
result, err := handler.Handle(context.Background(), cmd) result, err := handler.Handle(context.Background(), cmd)
@@ -97,10 +96,6 @@ func TestRegisterUserHandler_Success(t *testing.T) {
if createdUser.Email != cmd.Email { if createdUser.Email != cmd.Email {
t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email) t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email)
} }
if createdUser.Timezone != cmd.Timezone {
t.Errorf("expected timezone %q, got %q", cmd.Timezone, createdUser.Timezone)
}
} }
func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
@@ -125,7 +120,6 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: tt.email, Email: tt.email,
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -160,38 +154,6 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: tt.password, Password: tt.password,
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
timezone string
}{
{"empty timezone", ""},
{"invalid timezone", "InvalidTimezone"},
{"numeric format", "GMT+1"},
{"partial timezone", "America"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: tt.timezone,
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -203,7 +165,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
} }
func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
existingUser := entities.NewUser("test@example.com", "hashed", "UTC") existingUser := entities.NewUser("test@example.com", "hashed")
repo := &mockUserRepo{ repo := &mockUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return existingUser, nil return existingUser, nil
@@ -215,7 +177,6 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -237,7 +198,6 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -259,7 +219,6 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -283,7 +242,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{ RegisterUserCommand{
Email: "user+tag@example.com", Email: "user+tag@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
}, },
nil, nil,
}, },
@@ -292,16 +250,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{ RegisterUserCommand{
Email: "user@mail.example.com", Email: "user@mail.example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
},
nil,
},
{
"complex timezone",
RegisterUserCommand{
Email: "user@example.com",
Password: "Secure123!",
Timezone: "America/Argentina/Buenos_Aires",
}, },
nil, nil,
}, },
@@ -310,7 +258,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{ RegisterUserCommand{
Email: "user@example.com", Email: "user@example.com",
Password: "Sëcure123!", Password: "Sëcure123!",
Timezone: "UTC",
}, },
nil, nil,
}, },
@@ -319,7 +266,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{ RegisterUserCommand{
Email: "user@example.com", Email: "user@example.com",
Password: "ValidP@ss1" + string(make([]byte, 100)), Password: "ValidP@ss1" + string(make([]byte, 100)),
Timezone: "UTC",
}, },
nil, nil,
}, },
@@ -342,7 +288,6 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
Password: "Secure123!", Password: "Secure123!",
Timezone: "UTC",
} }
_, err := handler.Handle(context.Background(), cmd) _, err := handler.Handle(context.Background(), cmd)
@@ -98,7 +98,7 @@ func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error
} }
func TestResetPasswordHandler_Success(t *testing.T) { func TestResetPasswordHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "old_hash", "UTC") user := entities.NewUser("test@example.com", "old_hash")
user.ID = "user-123" user.ID = "user-123"
resetToken := entities.NewPasswordResetToken( resetToken := entities.NewPasswordResetToken(
@@ -340,7 +340,7 @@ func TestResetPasswordHandler_UserNotFound(t *testing.T) {
} }
func TestResetPasswordHandler_HashingError(t *testing.T) { func TestResetPasswordHandler_HashingError(t *testing.T) {
user := entities.NewUser("test@example.com", "old_hash", "UTC") user := entities.NewUser("test@example.com", "old_hash")
user.ID = "user-123" user.ID = "user-123"
resetToken := entities.NewPasswordResetToken( resetToken := entities.NewPasswordResetToken(
@@ -67,7 +67,7 @@ func TestVerifyEmailHandler_Success(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -148,7 +148,7 @@ func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = true user.EmailVerified = true
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -176,7 +176,7 @@ func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
token := "expired-token" token := "expired-token"
expiry := time.Now().Add(-1 * time.Hour) expiry := time.Now().Add(-1 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -203,7 +203,7 @@ func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
func TestVerifyEmailHandler_NilExpiry(t *testing.T) { func TestVerifyEmailHandler_NilExpiry(t *testing.T) {
token := "valid-token" token := "valid-token"
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -231,7 +231,7 @@ func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -277,7 +277,7 @@ func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -310,7 +310,7 @@ func TestVerifyEmailHandler_UpdateError(t *testing.T) {
token := "valid-token" token := "valid-token"
expiry := time.Now().Add(24 * time.Hour) expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword", "UTC") user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
user.EmailVerificationToken = &token user.EmailVerificationToken = &token
@@ -16,7 +16,6 @@ type LoginUserQuery struct {
type LoginUserResult struct { type LoginUserResult struct {
UserID string UserID string
Email string Email string
Timezone string
} }
type LoginUserHandler struct { type LoginUserHandler struct {
@@ -52,6 +51,5 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
return &LoginUserResult{ return &LoginUserResult{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Timezone: user.Timezone,
}, nil }, nil
} }
@@ -55,7 +55,7 @@ func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error
} }
func TestLoginUserHandler_Success(t *testing.T) { func TestLoginUserHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password", "UTC") user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = true user.EmailVerified = true
@@ -150,7 +150,7 @@ func TestLoginUserHandler_UserNotFound(t *testing.T) {
} }
func TestLoginUserHandler_InvalidPassword(t *testing.T) { func TestLoginUserHandler_InvalidPassword(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password", "UTC") user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = true user.EmailVerified = true
@@ -180,7 +180,7 @@ func TestLoginUserHandler_InvalidPassword(t *testing.T) {
} }
func TestLoginUserHandler_EmailNotVerified(t *testing.T) { func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password", "UTC") user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123" user.ID = "user-123"
user.EmailVerified = false user.EmailVerified = false
@@ -19,7 +19,6 @@ type RefreshTokenQuery struct {
type RefreshTokenResult struct { type RefreshTokenResult struct {
UserID string UserID string
Email string Email string
Timezone string
} }
type RefreshTokenHandler struct { type RefreshTokenHandler struct {
@@ -59,7 +58,6 @@ func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuer
return &RefreshTokenResult{ return &RefreshTokenResult{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Timezone: user.Timezone,
}, nil }, nil
} }
@@ -80,7 +80,7 @@ func TestRefreshTokenHandler_Success(t *testing.T) {
userRepo := &mockUserRepositoryForRefresh{ userRepo := &mockUserRepositoryForRefresh{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hash", "UTC") user := entities.NewUser("test@example.com", "hash")
user.ID = id user.ID = id
return user, nil return user, nil
}, },
+1 -6
View File
@@ -6,7 +6,6 @@ type User struct {
ID string ID string
Email string Email string
PasswordHash string PasswordHash string
Timezone string
EmailVerified bool EmailVerified bool
EmailVerificationToken *string EmailVerificationToken *string
EmailVerificationExpiry *time.Time EmailVerificationExpiry *time.Time
@@ -14,15 +13,11 @@ type User struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
func NewUser(email, passwordHash, timezone string) *User { func NewUser(email, passwordHash string) *User {
now := time.Now() now := time.Now()
if timezone == "" {
timezone = "UTC"
}
return &User{ return &User{
Email: email, Email: email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Timezone: timezone,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
+1 -14
View File
@@ -8,9 +8,8 @@ import (
func TestNewUser(t *testing.T) { func TestNewUser(t *testing.T) {
email := "test@example.com" email := "test@example.com"
passwordHash := "hashed_password_123" passwordHash := "hashed_password_123"
timezone := "Europe/Madrid"
user := NewUser(email, passwordHash, timezone) user := NewUser(email, passwordHash)
if user.Email != email { if user.Email != email {
t.Errorf("Expected email %s, got %s", email, user.Email) t.Errorf("Expected email %s, got %s", email, user.Email)
@@ -20,10 +19,6 @@ func TestNewUser(t *testing.T) {
t.Errorf("Expected password hash %s, got %s", passwordHash, user.PasswordHash) t.Errorf("Expected password hash %s, got %s", passwordHash, user.PasswordHash)
} }
if user.Timezone != timezone {
t.Errorf("Expected timezone %s, got %s", timezone, user.Timezone)
}
if user.CreatedAt.IsZero() { if user.CreatedAt.IsZero() {
t.Error("CreatedAt should not be zero") t.Error("CreatedAt should not be zero")
} }
@@ -37,11 +32,3 @@ func TestNewUser(t *testing.T) {
t.Errorf("CreatedAt and UpdatedAt should be nearly identical, diff: %v", diff) t.Errorf("CreatedAt and UpdatedAt should be nearly identical, diff: %v", diff)
} }
} }
func TestUser_DefaultTimezone(t *testing.T) {
user := NewUser("test@example.com", "hash", "")
if user.Timezone != "UTC" {
t.Errorf("Expected default timezone UTC, got %s", user.Timezone)
}
}
+3 -1
View File
@@ -46,7 +46,9 @@
"invalid_token_or_password": "Invalid or expired token, or password requirements not met", "invalid_token_or_password": "Invalid or expired token, or password requirements not met",
"failed_reset_password": "Failed to reset password", "failed_reset_password": "Failed to reset password",
"failed_delete_user": "Failed to delete user", "failed_delete_user": "Failed to delete user",
"failed_get_stats": "Failed to get statistics" "failed_get_stats": "Failed to get statistics",
"timezone_required": "Timezone is required",
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
}, },
"success": { "success": {
"registration_with_verification": "Registration successful. Please check your email to verify your account.", "registration_with_verification": "Registration successful. Please check your email to verify your account.",
+3 -1
View File
@@ -46,7 +46,9 @@
"invalid_token_or_password": "Token inválido o expirado, o no se cumplen los requisitos de contraseña", "invalid_token_or_password": "Token inválido o expirado, o no se cumplen los requisitos de contraseña",
"failed_reset_password": "Error al restablecer contraseña", "failed_reset_password": "Error al restablecer contraseña",
"failed_delete_user": "Error al eliminar usuario", "failed_delete_user": "Error al eliminar usuario",
"failed_get_stats": "Error al obtener estadísticas" "failed_get_stats": "Error al obtener estadísticas",
"timezone_required": "La zona horaria es requerida",
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
}, },
"success": { "success": {
"registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.", "registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.",
@@ -65,7 +65,6 @@ func NewAuthHandlers(
type RegisterRequest struct { type RegisterRequest struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
Timezone string `json:"timezone"`
} }
type LoginRequest struct { type LoginRequest struct {
@@ -100,7 +99,7 @@ type LogoutRequest struct {
// @Produce json // @Produce json
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)" // @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps" // @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
// @Failure 400 {object} ValidationErrorResponse "Invalid input: email format, password requirements, or timezone" // @Failure 400 {object} ValidationErrorResponse "Invalid input: email format or password requirements"
// @Failure 403 {object} ErrorResponse "Registration is closed" // @Failure 403 {object} ErrorResponse "Registration is closed"
// @Failure 409 {object} ErrorResponse "Email already registered" // @Failure 409 {object} ErrorResponse "Email already registered"
// @Failure 500 {object} ErrorResponse "Internal server error" // @Failure 500 {object} ErrorResponse "Internal server error"
@@ -115,7 +114,6 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
cmd := commands.RegisterUserCommand{ cmd := commands.RegisterUserCommand{
Email: req.Email, Email: req.Email,
Password: req.Password, Password: req.Password,
Timezone: req.Timezone,
} }
result, err := h.registerHandler.Handle(r.Context(), cmd) result, err := h.registerHandler.Handle(r.Context(), cmd)
+10 -11
View File
@@ -9,7 +9,6 @@ import (
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/i18n" "apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
@@ -26,7 +25,6 @@ type HabitHandlers struct {
archiveHandler *commands.ArchiveHabitHandler archiveHandler *commands.ArchiveHabitHandler
markHandler *commands.MarkHabitHandler markHandler *commands.MarkHabitHandler
unmarkHandler *commands.UnmarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler
userRepo repositories.UserRepository
translator *i18n.Translator translator *i18n.Translator
} }
@@ -40,7 +38,6 @@ func NewHabitHandlers(
archiveHandler *commands.ArchiveHabitHandler, archiveHandler *commands.ArchiveHabitHandler,
markHandler *commands.MarkHabitHandler, markHandler *commands.MarkHabitHandler,
unmarkHandler *commands.UnmarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler,
userRepo repositories.UserRepository,
translator *i18n.Translator, translator *i18n.Translator,
) *HabitHandlers { ) *HabitHandlers {
return &HabitHandlers{ return &HabitHandlers{
@@ -53,7 +50,6 @@ func NewHabitHandlers(
archiveHandler: archiveHandler, archiveHandler: archiveHandler,
markHandler: markHandler, markHandler: markHandler,
unmarkHandler: unmarkHandler, unmarkHandler: unmarkHandler,
userRepo: userRepo,
translator: translator, translator: translator,
} }
} }
@@ -440,11 +436,13 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
// GetTodaysHabits godoc // GetTodaysHabits godoc
// @Summary Get today's habits // @Summary Get today's habits
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. // @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).
// @Tags habits // @Tags habits
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"
// @Success 200 {array} TodaysHabitResponse // @Success 200 {array} TodaysHabitResponse
// @Failure 400 {object} ErrorResponse "Invalid or missing timezone"
// @Failure 401 {object} ErrorResponse // @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /habits/today [get] // @Router /habits/today [get]
@@ -455,15 +453,16 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
return return
} }
user, err := h.userRepo.FindByID(r.Context(), userID) timezone := r.URL.Query().Get("timezone")
if err != nil { if timezone == "" {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user") respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required")
return return
} }
loc, err := time.LoadLocation(user.Timezone) loc, err := time.LoadLocation(timezone)
if err != nil { if err != nil {
loc = time.UTC respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone")
return
} }
today := time.Now().In(loc) today := time.Now().In(loc)
@@ -471,7 +470,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
query := queries.GetTodaysHabitsQuery{ query := queries.GetTodaysHabitsQuery{
UserID: userID, UserID: userID,
Timezone: user.Timezone, Timezone: timezone,
Date: todayDate, Date: todayDate,
} }
@@ -70,7 +70,7 @@ func setupTestServer(t *testing.T) *TestServer {
translator, _ := i18n.NewTranslator() translator, _ := i18n.NewTranslator()
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator) authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo, translator) habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator) statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
healthHandlers := NewHealthHandlers(db) healthHandlers := NewHealthHandlers(db)
userHandlers := NewUserHandlers(deleteUserHandler, translator) userHandlers := NewUserHandlers(deleteUserHandler, translator)
@@ -25,6 +25,10 @@ func RunMigrations(db *sql.DB) error {
return err return err
} }
if err := removeTimezoneColumn(db); err != nil {
return err
}
return nil return nil
} }
@@ -54,6 +58,23 @@ func addEmailVerificationColumns(db *sql.DB) error {
return nil return nil
} }
func removeTimezoneColumn(db *sql.DB) error {
exists, err := columnExists(db, "users", "timezone")
if err != nil {
return err
}
if !exists {
return nil
}
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
return fmt.Errorf("failed to drop timezone column: %w", err)
}
return nil
}
func columnExists(db *sql.DB, table, column string) (bool, error) { func columnExists(db *sql.DB, table, column string) (bool, error) {
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table) query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
var count int var count int
@@ -69,7 +90,6 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
timezone TEXT DEFAULT 'UTC',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -24,15 +24,14 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.ID = uuid.New().String() user.ID = uuid.New().String()
query := ` query := `
INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at) INSERT INTO users (id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
` `
_, err := r.db.ExecContext(ctx, query, _, err := r.db.ExecContext(ctx, query,
user.ID, user.ID,
user.Email, user.Email,
user.PasswordHash, user.PasswordHash,
user.Timezone,
user.EmailVerified, user.EmailVerified,
user.EmailVerificationToken, user.EmailVerificationToken,
user.EmailVerificationExpiry, user.EmailVerificationExpiry,
@@ -52,7 +51,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) { func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
query := ` query := `
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users FROM users
WHERE id = ? WHERE id = ?
` `
@@ -62,7 +61,6 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
&user.ID, &user.ID,
&user.Email, &user.Email,
&user.PasswordHash, &user.PasswordHash,
&user.Timezone,
&user.EmailVerified, &user.EmailVerified,
&user.EmailVerificationToken, &user.EmailVerificationToken,
&user.EmailVerificationExpiry, &user.EmailVerificationExpiry,
@@ -82,7 +80,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
query := ` query := `
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users FROM users
WHERE email = ? WHERE email = ?
` `
@@ -92,7 +90,6 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
&user.ID, &user.ID,
&user.Email, &user.Email,
&user.PasswordHash, &user.PasswordHash,
&user.Timezone,
&user.EmailVerified, &user.EmailVerified,
&user.EmailVerificationToken, &user.EmailVerificationToken,
&user.EmailVerificationExpiry, &user.EmailVerificationExpiry,
@@ -112,7 +109,7 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
query := ` query := `
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users FROM users
WHERE email_verification_token = ? WHERE email_verification_token = ?
` `
@@ -122,7 +119,6 @@ func (r *UserRepository) FindByVerificationToken(ctx context.Context, token stri
&user.ID, &user.ID,
&user.Email, &user.Email,
&user.PasswordHash, &user.PasswordHash,
&user.Timezone,
&user.EmailVerified, &user.EmailVerified,
&user.EmailVerificationToken, &user.EmailVerificationToken,
&user.EmailVerificationExpiry, &user.EmailVerificationExpiry,
@@ -143,14 +139,13 @@ func (r *UserRepository) FindByVerificationToken(ctx context.Context, token stri
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error { func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
query := ` query := `
UPDATE users UPDATE users
SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ? SET email = ?, password_hash = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
WHERE id = ? WHERE id = ?
` `
result, err := r.db.ExecContext(ctx, query, result, err := r.db.ExecContext(ctx, query,
user.Email, user.Email,
user.PasswordHash, user.PasswordHash,
user.Timezone,
user.EmailVerified, user.EmailVerified,
user.EmailVerificationToken, user.EmailVerificationToken,
user.EmailVerificationExpiry, user.EmailVerificationExpiry,
@@ -35,7 +35,6 @@ func TestUserRepositoryCreate(t *testing.T) {
user := &entities.User{ user := &entities.User{
Email: "test@example.com", Email: "test@example.com",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -60,7 +59,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
user1 := &entities.User{ user1 := &entities.User{
Email: "duplicate@example.com", Email: "duplicate@example.com",
PasswordHash: "hash1", PasswordHash: "hash1",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -73,7 +71,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
user2 := &entities.User{ user2 := &entities.User{
Email: "duplicate@example.com", Email: "duplicate@example.com",
PasswordHash: "hash2", PasswordHash: "hash2",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -94,7 +91,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
user := &entities.User{ user := &entities.User{
Email: "find@example.com", Email: "find@example.com",
PasswordHash: "hashed", PasswordHash: "hashed",
Timezone: "America/New_York",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -115,8 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
if found.Email != user.Email { if found.Email != user.Email {
t.Errorf("Expected email %s, got %s", user.Email, found.Email) t.Errorf("Expected email %s, got %s", user.Email, found.Email)
} }
if found.Timezone != user.Timezone {
t.Errorf("Expected timezone %s, got %s", user.Timezone, found.Timezone)
} }
} }
@@ -143,7 +137,6 @@ func TestUserRepositoryFindByEmail(t *testing.T) {
user := &entities.User{ user := &entities.User{
Email: "email@test.com", Email: "email@test.com",
PasswordHash: "hashed", PasswordHash: "hashed",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -189,7 +182,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
user := &entities.User{ user := &entities.User{
Email: "original@example.com", Email: "original@example.com",
PasswordHash: "hash1", PasswordHash: "hash1",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -200,7 +192,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
} }
user.Email = "updated@example.com" user.Email = "updated@example.com"
user.Timezone = "Europe/Madrid"
user.UpdatedAt = time.Now() user.UpdatedAt = time.Now()
err = repo.Update(ctx, user) err = repo.Update(ctx, user)
@@ -216,8 +207,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
if found.Email != "updated@example.com" { if found.Email != "updated@example.com" {
t.Errorf("Expected email updated@example.com, got %s", found.Email) t.Errorf("Expected email updated@example.com, got %s", found.Email)
} }
if found.Timezone != "Europe/Madrid" {
t.Errorf("Expected timezone Europe/Madrid, got %s", found.Timezone)
} }
} }
@@ -232,7 +221,6 @@ func TestUserRepositoryUpdateNotFound(t *testing.T) {
ID: "non-existent", ID: "non-existent",
Email: "test@example.com", Email: "test@example.com",
PasswordHash: "hash", PasswordHash: "hash",
Timezone: "UTC",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
+1 -5
View File
@@ -114,7 +114,7 @@ func ValidateTimezone(timezone string) error {
return nil return nil
} }
func ValidateRegistration(email, password, timezone string) error { func ValidateRegistration(email, password string) error {
if err := ValidateEmail(email); err != nil { if err := ValidateEmail(email); err != nil {
return err return err
} }
@@ -123,9 +123,5 @@ func ValidateRegistration(email, password, timezone string) error {
return err return err
} }
if err := ValidateTimezone(timezone); err != nil {
return err
}
return nil return nil
} }