From 5d928205913f770c903c716ae0afd17686722dab Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Sat, 29 Nov 2025 16:18:04 +0100 Subject: [PATCH] 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 --- cmd/api/main.go | 2 +- .../application/commands/delete_user_test.go | 4 +- .../application/commands/register_user.go | 5 +- .../commands/register_user_test.go | 57 +------------------ .../commands/reset_password_test.go | 4 +- .../application/commands/verify_email_test.go | 14 ++--- internal/application/queries/login_user.go | 10 ++-- .../application/queries/login_user_test.go | 6 +- internal/application/queries/refresh_token.go | 10 ++-- .../application/queries/refresh_token_test.go | 2 +- internal/domain/entities/user.go | 7 +-- internal/domain/entities/user_test.go | 15 +---- internal/i18n/locales/en.json | 4 +- internal/i18n/locales/es.json | 4 +- internal/infrastructure/http/auth_handlers.go | 4 +- .../infrastructure/http/habit_handlers.go | 21 ++++--- .../infrastructure/http/integration_test.go | 2 +- .../persistence/sqlite/migrations.go | 22 ++++++- .../persistence/sqlite/user_repository.go | 17 ++---- .../sqlite/user_repository_test.go | 12 ---- internal/shared/validation/validator.go | 6 +- 21 files changed, 75 insertions(+), 153 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index e449e73..163a529 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -114,7 +114,7 @@ func main() { unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) 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) healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator) diff --git a/internal/application/commands/delete_user_test.go b/internal/application/commands/delete_user_test.go index 047046e..3f1ad0b 100644 --- a/internal/application/commands/delete_user_test.go +++ b/internal/application/commands/delete_user_test.go @@ -48,7 +48,7 @@ func TestDeleteUserHandler_Success(t *testing.T) { repo := &mockDeleteUserRepo{ 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 return user, nil }, @@ -112,7 +112,7 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) { repo := &mockDeleteUserRepo{ 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 return user, nil }, diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go index 4d63dbf..6c32839 100644 --- a/internal/application/commands/register_user.go +++ b/internal/application/commands/register_user.go @@ -17,7 +17,6 @@ import ( type RegisterUserCommand struct { Email string Password string - Timezone string } type RegisterUserResult struct { @@ -57,7 +56,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman 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) } @@ -71,7 +70,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman return nil, err } - user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone) + user := entities.NewUser(cmd.Email, hashedPassword) emailVerificationRequired := false if h.emailService != nil { diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index 88b532b..1b26814 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -74,7 +74,6 @@ func TestRegisterUserHandler_Success(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", Password: "Secure123!", - Timezone: "UTC", } result, err := handler.Handle(context.Background(), cmd) @@ -97,10 +96,6 @@ func TestRegisterUserHandler_Success(t *testing.T) { if createdUser.Email != cmd.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) { @@ -125,7 +120,6 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) { cmd := RegisterUserCommand{ Email: tt.email, Password: "Secure123!", - Timezone: "UTC", } _, err := handler.Handle(context.Background(), cmd) @@ -160,38 +154,6 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", 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) @@ -203,7 +165,7 @@ func TestRegisterUserHandler_InvalidTimezone(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{ findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { return existingUser, nil @@ -215,7 +177,6 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", Password: "Secure123!", - Timezone: "UTC", } _, err := handler.Handle(context.Background(), cmd) @@ -237,7 +198,6 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", Password: "Secure123!", - Timezone: "UTC", } _, err := handler.Handle(context.Background(), cmd) @@ -259,7 +219,6 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", Password: "Secure123!", - Timezone: "UTC", } _, err := handler.Handle(context.Background(), cmd) @@ -283,7 +242,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { RegisterUserCommand{ Email: "user+tag@example.com", Password: "Secure123!", - Timezone: "UTC", }, nil, }, @@ -292,16 +250,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { RegisterUserCommand{ Email: "user@mail.example.com", Password: "Secure123!", - Timezone: "UTC", - }, - nil, - }, - { - "complex timezone", - RegisterUserCommand{ - Email: "user@example.com", - Password: "Secure123!", - Timezone: "America/Argentina/Buenos_Aires", }, nil, }, @@ -310,7 +258,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { RegisterUserCommand{ Email: "user@example.com", Password: "Sëcure123!", - Timezone: "UTC", }, nil, }, @@ -319,7 +266,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { RegisterUserCommand{ Email: "user@example.com", Password: "ValidP@ss1" + string(make([]byte, 100)), - Timezone: "UTC", }, nil, }, @@ -342,7 +288,6 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { cmd := RegisterUserCommand{ Email: "test@example.com", Password: "Secure123!", - Timezone: "UTC", } _, err := handler.Handle(context.Background(), cmd) diff --git a/internal/application/commands/reset_password_test.go b/internal/application/commands/reset_password_test.go index 46a8a29..52610f5 100644 --- a/internal/application/commands/reset_password_test.go +++ b/internal/application/commands/reset_password_test.go @@ -98,7 +98,7 @@ func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error } 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" resetToken := entities.NewPasswordResetToken( @@ -340,7 +340,7 @@ func TestResetPasswordHandler_UserNotFound(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" resetToken := entities.NewPasswordResetToken( diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go index 7fffe4f..9c8c877 100644 --- a/internal/application/commands/verify_email_test.go +++ b/internal/application/commands/verify_email_test.go @@ -67,7 +67,7 @@ func TestVerifyEmailHandler_Success(t *testing.T) { token := "valid-token" 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.EmailVerified = false user.EmailVerificationToken = &token @@ -148,7 +148,7 @@ func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) { token := "valid-token" 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.EmailVerified = true user.EmailVerificationToken = &token @@ -176,7 +176,7 @@ func TestVerifyEmailHandler_ExpiredToken(t *testing.T) { token := "expired-token" 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.EmailVerified = false user.EmailVerificationToken = &token @@ -203,7 +203,7 @@ func TestVerifyEmailHandler_ExpiredToken(t *testing.T) { func TestVerifyEmailHandler_NilExpiry(t *testing.T) { token := "valid-token" - user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user := entities.NewUser("test@example.com", "hashedPassword") user.ID = "user-123" user.EmailVerified = false user.EmailVerificationToken = &token @@ -231,7 +231,7 @@ func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) { token := "valid-token" 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.EmailVerified = false user.EmailVerificationToken = &token @@ -277,7 +277,7 @@ func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) { token := "valid-token" 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.EmailVerified = false user.EmailVerificationToken = &token @@ -310,7 +310,7 @@ func TestVerifyEmailHandler_UpdateError(t *testing.T) { token := "valid-token" 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.EmailVerified = false user.EmailVerificationToken = &token diff --git a/internal/application/queries/login_user.go b/internal/application/queries/login_user.go index fc8af98..d670bb6 100644 --- a/internal/application/queries/login_user.go +++ b/internal/application/queries/login_user.go @@ -14,9 +14,8 @@ type LoginUserQuery struct { } type LoginUserResult struct { - UserID string - Email string - Timezone string + UserID string + Email string } type LoginUserHandler struct { @@ -50,8 +49,7 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L } return &LoginUserResult{ - UserID: user.ID, - Email: user.Email, - Timezone: user.Timezone, + UserID: user.ID, + Email: user.Email, }, nil } diff --git a/internal/application/queries/login_user_test.go b/internal/application/queries/login_user_test.go index 672b62d..c2edda9 100644 --- a/internal/application/queries/login_user_test.go +++ b/internal/application/queries/login_user_test.go @@ -55,7 +55,7 @@ func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error } 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.EmailVerified = true @@ -150,7 +150,7 @@ func TestLoginUserHandler_UserNotFound(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.EmailVerified = true @@ -180,7 +180,7 @@ func TestLoginUserHandler_InvalidPassword(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.EmailVerified = false diff --git a/internal/application/queries/refresh_token.go b/internal/application/queries/refresh_token.go index bf5bb45..06aa2bc 100644 --- a/internal/application/queries/refresh_token.go +++ b/internal/application/queries/refresh_token.go @@ -17,9 +17,8 @@ type RefreshTokenQuery struct { } type RefreshTokenResult struct { - UserID string - Email string - Timezone string + UserID string + Email string } type RefreshTokenHandler struct { @@ -57,9 +56,8 @@ func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuer } return &RefreshTokenResult{ - UserID: user.ID, - Email: user.Email, - Timezone: user.Timezone, + UserID: user.ID, + Email: user.Email, }, nil } diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index a5e0401..a82531c 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -80,7 +80,7 @@ func TestRefreshTokenHandler_Success(t *testing.T) { userRepo := &mockUserRepositoryForRefresh{ 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 return user, nil }, diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index 45d1e12..2a9469d 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -6,7 +6,6 @@ type User struct { ID string Email string PasswordHash string - Timezone string EmailVerified bool EmailVerificationToken *string EmailVerificationExpiry *time.Time @@ -14,15 +13,11 @@ type User struct { UpdatedAt time.Time } -func NewUser(email, passwordHash, timezone string) *User { +func NewUser(email, passwordHash string) *User { now := time.Now() - if timezone == "" { - timezone = "UTC" - } return &User{ Email: email, PasswordHash: passwordHash, - Timezone: timezone, CreatedAt: now, UpdatedAt: now, } diff --git a/internal/domain/entities/user_test.go b/internal/domain/entities/user_test.go index e2e0b65..d9c75bd 100644 --- a/internal/domain/entities/user_test.go +++ b/internal/domain/entities/user_test.go @@ -8,9 +8,8 @@ import ( func TestNewUser(t *testing.T) { email := "test@example.com" passwordHash := "hashed_password_123" - timezone := "Europe/Madrid" - user := NewUser(email, passwordHash, timezone) + user := NewUser(email, passwordHash) if user.Email != 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) } - if user.Timezone != timezone { - t.Errorf("Expected timezone %s, got %s", timezone, user.Timezone) - } - if user.CreatedAt.IsZero() { 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) } } - -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) - } -} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 0e34a7c..ed54c30 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -46,7 +46,9 @@ "invalid_token_or_password": "Invalid or expired token, or password requirements not met", "failed_reset_password": "Failed to reset password", "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": { "registration_with_verification": "Registration successful. Please check your email to verify your account.", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 6a59f0e..b826d6d 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -46,7 +46,9 @@ "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_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": { "registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.", diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index 05ecc43..45dbbe8 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -65,7 +65,6 @@ func NewAuthHandlers( type RegisterRequest struct { Email string `json:"email"` Password string `json:"password"` - Timezone string `json:"timezone"` } type LoginRequest struct { @@ -100,7 +99,7 @@ type LogoutRequest struct { // @Produce json // @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" -// @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 409 {object} ErrorResponse "Email already registered" // @Failure 500 {object} ErrorResponse "Internal server error" @@ -115,7 +114,6 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { cmd := commands.RegisterUserCommand{ Email: req.Email, Password: req.Password, - Timezone: req.Timezone, } result, err := h.registerHandler.Handle(r.Context(), cmd) diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 547656b..c4d61a3 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -9,7 +9,6 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" - "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/i18n" "apocapoc-api/internal/shared/errors" @@ -26,7 +25,6 @@ type HabitHandlers struct { archiveHandler *commands.ArchiveHabitHandler markHandler *commands.MarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler - userRepo repositories.UserRepository translator *i18n.Translator } @@ -40,7 +38,6 @@ func NewHabitHandlers( archiveHandler *commands.ArchiveHabitHandler, markHandler *commands.MarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler, - userRepo repositories.UserRepository, translator *i18n.Translator, ) *HabitHandlers { return &HabitHandlers{ @@ -53,7 +50,6 @@ func NewHabitHandlers( archiveHandler: archiveHandler, markHandler: markHandler, unmarkHandler: unmarkHandler, - userRepo: userRepo, translator: translator, } } @@ -440,11 +436,13 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) // GetTodaysHabits godoc // @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 // @Produce json // @Security BearerAuth +// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')" // @Success 200 {array} TodaysHabitResponse +// @Failure 400 {object} ErrorResponse "Invalid or missing timezone" // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /habits/today [get] @@ -455,15 +453,16 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) return } - user, err := h.userRepo.FindByID(r.Context(), userID) - if err != nil { - respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user") + timezone := r.URL.Query().Get("timezone") + if timezone == "" { + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required") return } - loc, err := time.LoadLocation(user.Timezone) + loc, err := time.LoadLocation(timezone) if err != nil { - loc = time.UTC + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone") + return } today := time.Now().In(loc) @@ -471,7 +470,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) query := queries.GetTodaysHabitsQuery{ UserID: userID, - Timezone: user.Timezone, + Timezone: timezone, Date: todayDate, } diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 693e2dc..a7efdb6 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -70,7 +70,7 @@ func setupTestServer(t *testing.T) *TestServer { translator, _ := i18n.NewTranslator() 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) healthHandlers := NewHealthHandlers(db) userHandlers := NewUserHandlers(deleteUserHandler, translator) diff --git a/internal/infrastructure/persistence/sqlite/migrations.go b/internal/infrastructure/persistence/sqlite/migrations.go index 02cfd26..acf0d66 100644 --- a/internal/infrastructure/persistence/sqlite/migrations.go +++ b/internal/infrastructure/persistence/sqlite/migrations.go @@ -25,6 +25,10 @@ func RunMigrations(db *sql.DB) error { return err } + if err := removeTimezoneColumn(db); err != nil { + return err + } + return nil } @@ -54,6 +58,23 @@ func addEmailVerificationColumns(db *sql.DB) error { 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) { query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table) var count int @@ -69,7 +90,6 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, - timezone TEXT DEFAULT 'UTC', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/infrastructure/persistence/sqlite/user_repository.go b/internal/infrastructure/persistence/sqlite/user_repository.go index fdc2f41..1e8116e 100644 --- a/internal/infrastructure/persistence/sqlite/user_repository.go +++ b/internal/infrastructure/persistence/sqlite/user_repository.go @@ -24,15 +24,14 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error user.ID = uuid.New().String() query := ` - INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ` _, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.PasswordHash, - user.Timezone, user.EmailVerified, user.EmailVerificationToken, 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) { 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 WHERE id = ? ` @@ -62,7 +61,6 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use &user.ID, &user.Email, &user.PasswordHash, - &user.Timezone, &user.EmailVerified, &user.EmailVerificationToken, &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) { 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 WHERE email = ? ` @@ -92,7 +90,6 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti &user.ID, &user.Email, &user.PasswordHash, - &user.Timezone, &user.EmailVerified, &user.EmailVerificationToken, &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) { 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 WHERE email_verification_token = ? ` @@ -122,7 +119,6 @@ func (r *UserRepository) FindByVerificationToken(ctx context.Context, token stri &user.ID, &user.Email, &user.PasswordHash, - &user.Timezone, &user.EmailVerified, &user.EmailVerificationToken, &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 { query := ` 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 = ? ` result, err := r.db.ExecContext(ctx, query, user.Email, user.PasswordHash, - user.Timezone, user.EmailVerified, user.EmailVerificationToken, user.EmailVerificationExpiry, diff --git a/internal/infrastructure/persistence/sqlite/user_repository_test.go b/internal/infrastructure/persistence/sqlite/user_repository_test.go index 748bba9..93b54b4 100644 --- a/internal/infrastructure/persistence/sqlite/user_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/user_repository_test.go @@ -35,7 +35,6 @@ func TestUserRepositoryCreate(t *testing.T) { user := &entities.User{ Email: "test@example.com", PasswordHash: "hashed_password", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -60,7 +59,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) { user1 := &entities.User{ Email: "duplicate@example.com", PasswordHash: "hash1", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -73,7 +71,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) { user2 := &entities.User{ Email: "duplicate@example.com", PasswordHash: "hash2", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -94,7 +91,6 @@ func TestUserRepositoryFindByID(t *testing.T) { user := &entities.User{ Email: "find@example.com", PasswordHash: "hashed", - Timezone: "America/New_York", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -115,8 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) { if found.Email != user.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{ Email: "email@test.com", PasswordHash: "hashed", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -189,7 +182,6 @@ func TestUserRepositoryUpdate(t *testing.T) { user := &entities.User{ Email: "original@example.com", PasswordHash: "hash1", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -200,7 +192,6 @@ func TestUserRepositoryUpdate(t *testing.T) { } user.Email = "updated@example.com" - user.Timezone = "Europe/Madrid" user.UpdatedAt = time.Now() err = repo.Update(ctx, user) @@ -216,8 +207,6 @@ func TestUserRepositoryUpdate(t *testing.T) { if found.Email != "updated@example.com" { 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", Email: "test@example.com", PasswordHash: "hash", - Timezone: "UTC", CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/internal/shared/validation/validator.go b/internal/shared/validation/validator.go index 0b763f8..c889e01 100644 --- a/internal/shared/validation/validator.go +++ b/internal/shared/validation/validator.go @@ -114,7 +114,7 @@ func ValidateTimezone(timezone string) error { return nil } -func ValidateRegistration(email, password, timezone string) error { +func ValidateRegistration(email, password string) error { if err := ValidateEmail(email); err != nil { return err } @@ -123,9 +123,5 @@ func ValidateRegistration(email, password, timezone string) error { return err } - if err := ValidateTimezone(timezone); err != nil { - return err - } - return nil }