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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user