diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 51be7bd..5bce75d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.24' - name: Run tests run: go test ./... -v -race -coverprofile=coverage.txt -covermode=atomic @@ -40,7 +40,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.24' - name: Run go vet run: go vet ./... @@ -69,7 +69,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.24' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/cmd/api/main.go b/cmd/api/main.go index f337944..e449e73 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -10,6 +10,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/i18n" "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/config" "apocapoc-api/internal/infrastructure/crypto" @@ -86,6 +87,11 @@ func main() { refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn()) passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db.Conn()) + translator, err := i18n.NewTranslator() + if err != nil { + log.Fatalf("Failed to create translator: %v", err) + } + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) @@ -107,13 +113,13 @@ func main() { markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) - authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) - habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo) - statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler) + 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) + statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator) healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) - userHandlers := httpInfra.NewUserHandlers(deleteUserHandler) + userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator) - router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService) + router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator) addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port) log.Printf("Server starting on %s", addr) diff --git a/go.mod b/go.mod index f9cea45..c702ace 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,8 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 960c17b..c7c0ac7 100644 --- a/go.sum +++ b/go.sum @@ -70,11 +70,13 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -82,9 +84,13 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..bcaa2f4 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,122 @@ +package i18n + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/text/language" +) + +//go:embed locales/en.json +var enTranslations []byte + +//go:embed locales/es.json +var esTranslations []byte + +type Translations struct { + Errors map[string]string `json:"errors"` + Success map[string]string `json:"success"` + Validation map[string]string `json:"validation"` + Emails map[string]string `json:"emails"` +} + +type Translator struct { + translations map[language.Tag]Translations + matcher language.Matcher +} + +func NewTranslator() (*Translator, error) { + var enTrans, esTrans Translations + + if err := json.Unmarshal(enTranslations, &enTrans); err != nil { + return nil, fmt.Errorf("failed to load English translations: %w", err) + } + + if err := json.Unmarshal(esTranslations, &esTrans); err != nil { + return nil, fmt.Errorf("failed to load Spanish translations: %w", err) + } + + translations := map[language.Tag]Translations{ + language.English: enTrans, + language.Spanish: esTrans, + } + + matcher := language.NewMatcher([]language.Tag{ + language.English, + language.Spanish, + }) + + return &Translator{ + translations: translations, + matcher: matcher, + }, nil +} + +func (t *Translator) GetLanguage(acceptLanguage string) language.Tag { + if acceptLanguage == "" { + return language.English + } + + tags, _, err := language.ParseAcceptLanguage(acceptLanguage) + if err != nil || len(tags) == 0 { + return language.English + } + + _, index, _ := t.matcher.Match(tags...) + supportedTags := []language.Tag{language.English, language.Spanish} + if index < len(supportedTags) { + return supportedTags[index] + } + + return language.English +} + +func (t *Translator) Translate(lang language.Tag, category, key string) string { + trans, ok := t.translations[lang] + if !ok { + trans = t.translations[language.English] + } + + var categoryMap map[string]string + switch category { + case "errors": + categoryMap = trans.Errors + case "success": + categoryMap = trans.Success + case "validation": + categoryMap = trans.Validation + case "emails": + categoryMap = trans.Emails + default: + return key + } + + if value, ok := categoryMap[key]; ok { + return value + } + + return key +} + +func (t *Translator) Error(lang language.Tag, key string) string { + return t.Translate(lang, "errors", key) +} + +func (t *Translator) Success(lang language.Tag, key string) string { + return t.Translate(lang, "success", key) +} + +func (t *Translator) Validation(lang language.Tag, key string) string { + return t.Translate(lang, "validation", key) +} + +func (t *Translator) Email(lang language.Tag, key string) string { + return t.Translate(lang, "emails", key) +} + +func (t *Translator) TranslateValidationError(lang language.Tag, field, validationKey string) string { + message := t.Validation(lang, validationKey) + return strings.ReplaceAll(message, field, field) +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..01daefc --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,252 @@ +package i18n + +import ( + "testing" + + "golang.org/x/text/language" +) + +func TestNewTranslator(t *testing.T) { + translator, err := NewTranslator() + if err != nil { + t.Fatalf("Failed to create translator: %v", err) + } + + if translator == nil { + t.Fatal("Expected translator to be non-nil") + } + + if translator.translations == nil { + t.Fatal("Expected translations map to be initialized") + } + + if len(translator.translations) != 2 { + t.Errorf("Expected 2 languages, got %d", len(translator.translations)) + } +} + +func TestGetLanguage(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + acceptLanguage string + expected language.Tag + }{ + { + name: "English", + acceptLanguage: "en-US", + expected: language.English, + }, + { + name: "Spanish", + acceptLanguage: "es-ES", + expected: language.Spanish, + }, + { + name: "Empty defaults to English", + acceptLanguage: "", + expected: language.English, + }, + { + name: "Unknown language defaults to English", + acceptLanguage: "fr-FR", + expected: language.English, + }, + { + name: "Spanish with quality", + acceptLanguage: "es-ES,es;q=0.9", + expected: language.Spanish, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.GetLanguage(tt.acceptLanguage) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestError(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + lang language.Tag + key string + expected string + }{ + { + name: "English error message", + lang: language.English, + key: "invalid_request_body", + expected: "Invalid request body", + }, + { + name: "Spanish error message", + lang: language.Spanish, + key: "invalid_request_body", + expected: "Cuerpo de solicitud inválido", + }, + { + name: "Missing key returns key", + lang: language.English, + key: "non_existent_key", + expected: "non_existent_key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.Error(tt.lang, tt.key) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestSuccess(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + lang language.Tag + key string + expected string + }{ + { + name: "English success message", + lang: language.English, + key: "logged_out", + expected: "Successfully logged out", + }, + { + name: "Spanish success message", + lang: language.Spanish, + key: "logged_out", + expected: "Sesión cerrada exitosamente", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.Success(tt.lang, tt.key) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestValidation(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + lang language.Tag + key string + expected string + }{ + { + name: "English validation message", + lang: language.English, + key: "email_required", + expected: "email is required", + }, + { + name: "Spanish validation message", + lang: language.Spanish, + key: "email_required", + expected: "el email es requerido", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.Validation(tt.lang, tt.key) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestEmail(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + lang language.Tag + key string + expected string + }{ + { + name: "English email message", + lang: language.English, + key: "welcome_subject", + expected: "Welcome to Apocapoc!", + }, + { + name: "Spanish email message", + lang: language.Spanish, + key: "welcome_subject", + expected: "¡Bienvenido a Apocapoc!", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.Email(tt.lang, tt.key) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestTranslate(t *testing.T) { + translator, _ := NewTranslator() + + tests := []struct { + name string + lang language.Tag + category string + key string + expected string + }{ + { + name: "Valid category and key", + lang: language.English, + category: "errors", + key: "user_not_found", + expected: "User not found", + }, + { + name: "Invalid category returns key", + lang: language.English, + category: "invalid_category", + key: "some_key", + expected: "some_key", + }, + { + name: "Unsupported language fallback to English", + lang: language.French, + category: "errors", + key: "user_not_found", + expected: "User not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := translator.Translate(tt.lang, tt.category, tt.key) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json new file mode 100644 index 0000000..0e34a7c --- /dev/null +++ b/internal/i18n/locales/en.json @@ -0,0 +1,109 @@ +{ + "errors": { + "invalid_request_body": "Invalid request body", + "email_already_registered": "Email already registered", + "registration_closed": "Registration is currently closed", + "failed_register_user": "Failed to register user", + "invalid_credentials": "Invalid email or password", + "email_not_verified": "Please verify your email before logging in", + "failed_login": "Failed to login", + "failed_generate_token": "Failed to generate token", + "failed_create_refresh_token": "Failed to create refresh token", + "failed_save_refresh_token": "Failed to save refresh token", + "invalid_expired_refresh_token": "Invalid or expired refresh token", + "failed_refresh_token": "Failed to refresh token", + "refresh_token_not_found": "Refresh token not found", + "invalid_refresh_token": "Invalid refresh token", + "user_not_authenticated": "User not authenticated", + "failed_get_user": "Failed to get user", + "failed_get_habits": "Failed to get habits", + "failed_create_habit": "Failed to create habit", + "habit_not_found": "Habit not found", + "access_denied": "Access denied", + "failed_get_habit": "Failed to get habit", + "invalid_input": "Invalid input", + "failed_update_habit": "Failed to update habit", + "failed_archive_habit": "Failed to archive habit", + "failed_get_habit_entries": "Failed to get habit entries", + "invalid_date_format": "Invalid date format (use YYYY-MM-DD)", + "invalid_page_parameter": "Invalid 'page' parameter", + "invalid_limit_parameter": "Invalid 'limit' parameter (must be 1-100)", + "pagination_required": "Pagination required: provide 'limit' parameter or use date range ≤ 1 year", + "invalid_from_date_format": "Invalid 'from' date format (use YYYY-MM-DD)", + "invalid_to_date_format": "Invalid 'to' date format (use YYYY-MM-DD)", + "habit_already_marked": "Habit already marked for this date", + "failed_mark_habit": "Failed to mark habit", + "habit_entry_not_found": "Habit entry not found", + "failed_unmark_habit": "Failed to unmark habit", + "invalid_expired_verification_token": "Invalid or expired verification token", + "email_already_verified": "Email already verified", + "failed_verify_email": "Failed to verify email", + "invalid_email": "Invalid email", + "user_not_found": "User not found", + "failed_send_verification_email": "Failed to send verification email", + "email_not_verified_reset": "Please verify your email before resetting password", + "failed_send_reset_email": "Failed to send reset email", + "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" + }, + "success": { + "registration_with_verification": "Registration successful. Please check your email to verify your account.", + "registration_without_verification": "Registration successful. You can now login.", + "logged_out": "Successfully logged out", + "email_verified": "Email verified successfully", + "verification_email_sent": "Verification email sent successfully", + "password_reset_email_sent": "Password reset email sent successfully", + "password_reset": "Password reset successfully", + "user_deleted": "User and all associated data deleted successfully" + }, + "validation": { + "email_required": "email is required", + "email_invalid_format": "email must be a valid email address", + "email_too_long": "email must not exceed 254 characters", + "password_required": "password is required", + "password_min_length": "password must be at least 8 characters long", + "password_max_length": "password must not exceed 128 characters", + "password_uppercase": "password must contain at least one uppercase letter", + "password_lowercase": "password must contain at least one lowercase letter", + "password_digit": "password must contain at least one digit", + "password_special_char": "password must contain at least one special character (!@#$%^&*)", + "timezone_required": "timezone is required", + "timezone_invalid": "timezone is not a valid IANA timezone", + "name_required": "name is required", + "name_too_long": "name must not exceed 255 characters", + "type_invalid": "type must be one of: BOOLEAN, COUNTER, VALUE", + "frequency_invalid": "frequency must be one of: DAILY, WEEKLY, MONTHLY", + "specific_days_required": "specific_days is required for WEEKLY frequency", + "specific_days_invalid": "specific_days must contain values between 0-6 (0=Sunday, 6=Saturday)", + "specific_dates_required": "specific_dates is required for MONTHLY frequency", + "specific_dates_invalid": "specific_dates must contain values between 1-31", + "target_value_required": "target_value is required for VALUE type", + "target_value_positive": "target_value must be positive" + }, + "emails": { + "verify_email_subject": "Verify your email address", + "verify_email_title": "Welcome! Please verify your email", + "verify_email_body": "Thank you for registering. Please click the link below to verify your email address:", + "verify_email_link": "Verify Email", + "verify_email_expiry": "This link will expire in 24 hours.", + "verify_email_ignore": "If you didn't create an account, you can safely ignore this email.", + "resend_verification_subject": "Verify your email address", + "resend_verification_title": "Verify your email address", + "resend_verification_body": "Please click the link below to verify your email address:", + "resend_verification_link": "Verify Email", + "resend_verification_expiry": "This link will expire in 24 hours.", + "resend_verification_ignore": "If you didn't request this, you can safely ignore this email.", + "password_reset_subject": "Password Reset Request", + "password_reset_title": "Password Reset Request", + "password_reset_body": "You have requested to reset your password. Please click the link below:", + "password_reset_link": "Reset Password", + "password_reset_expiry": "This link will expire in 1 hour.", + "password_reset_ignore": "If you didn't request this, you can safely ignore this email.", + "welcome_subject": "Welcome to Apocapoc!", + "welcome_title": "Welcome to Apocapoc!", + "welcome_body": "Your email has been verified successfully. You can now start using all features of Apocapoc.", + "welcome_enjoy": "Enjoy building better habits!" + } +} diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json new file mode 100644 index 0000000..6a59f0e --- /dev/null +++ b/internal/i18n/locales/es.json @@ -0,0 +1,109 @@ +{ + "errors": { + "invalid_request_body": "Cuerpo de solicitud inválido", + "email_already_registered": "El correo electrónico ya está registrado", + "registration_closed": "El registro está actualmente cerrado", + "failed_register_user": "Error al registrar usuario", + "invalid_credentials": "Correo electrónico o contraseña inválidos", + "email_not_verified": "Por favor verifica tu correo electrónico antes de iniciar sesión", + "failed_login": "Error al iniciar sesión", + "failed_generate_token": "Error al generar token", + "failed_create_refresh_token": "Error al crear token de actualización", + "failed_save_refresh_token": "Error al guardar token de actualización", + "invalid_expired_refresh_token": "Token de actualización inválido o expirado", + "failed_refresh_token": "Error al actualizar token", + "refresh_token_not_found": "Token de actualización no encontrado", + "invalid_refresh_token": "Token de actualización inválido", + "user_not_authenticated": "Usuario no autenticado", + "failed_get_user": "Error al obtener usuario", + "failed_get_habits": "Error al obtener hábitos", + "failed_create_habit": "Error al crear hábito", + "habit_not_found": "Hábito no encontrado", + "access_denied": "Acceso denegado", + "failed_get_habit": "Error al obtener hábito", + "invalid_input": "Entrada inválida", + "failed_update_habit": "Error al actualizar hábito", + "failed_archive_habit": "Error al archivar hábito", + "failed_get_habit_entries": "Error al obtener entradas de hábito", + "invalid_date_format": "Formato de fecha inválido (usa AAAA-MM-DD)", + "invalid_page_parameter": "Parámetro 'page' inválido", + "invalid_limit_parameter": "Parámetro 'limit' inválido (debe ser 1-100)", + "pagination_required": "Se requiere paginación: proporciona el parámetro 'limit' o usa un rango de fechas ≤ 1 año", + "invalid_from_date_format": "Formato de fecha 'from' inválido (usa AAAA-MM-DD)", + "invalid_to_date_format": "Formato de fecha 'to' inválido (usa AAAA-MM-DD)", + "habit_already_marked": "El hábito ya está marcado para esta fecha", + "failed_mark_habit": "Error al marcar hábito", + "habit_entry_not_found": "Entrada de hábito no encontrada", + "failed_unmark_habit": "Error al desmarcar hábito", + "invalid_expired_verification_token": "Token de verificación inválido o expirado", + "email_already_verified": "El correo electrónico ya está verificado", + "failed_verify_email": "Error al verificar correo electrónico", + "invalid_email": "Correo electrónico inválido", + "user_not_found": "Usuario no encontrado", + "failed_send_verification_email": "Error al enviar correo de verificación", + "email_not_verified_reset": "Por favor verifica tu correo electrónico antes de restablecer la contraseña", + "failed_send_reset_email": "Error al enviar correo de restablecimiento", + "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" + }, + "success": { + "registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.", + "registration_without_verification": "Registro exitoso. Ya puedes iniciar sesión.", + "logged_out": "Sesión cerrada exitosamente", + "email_verified": "Correo electrónico verificado exitosamente", + "verification_email_sent": "Correo de verificación enviado exitosamente", + "password_reset_email_sent": "Correo de restablecimiento de contraseña enviado exitosamente", + "password_reset": "Contraseña restablecida exitosamente", + "user_deleted": "Usuario y todos los datos asociados eliminados exitosamente" + }, + "validation": { + "email_required": "el email es requerido", + "email_invalid_format": "el email debe ser una dirección de correo válida", + "email_too_long": "el email no debe exceder 254 caracteres", + "password_required": "la password es requerida", + "password_min_length": "la password debe tener al menos 8 caracteres", + "password_max_length": "la password no debe exceder 128 caracteres", + "password_uppercase": "la password debe contener al menos una letra mayúscula", + "password_lowercase": "la password debe contener al menos una letra minúscula", + "password_digit": "la password debe contener al menos un dígito", + "password_special_char": "la password debe contener al menos un carácter especial (!@#$%^&*)", + "timezone_required": "la timezone es requerida", + "timezone_invalid": "la timezone no es una zona horaria IANA válida", + "name_required": "el name es requerido", + "name_too_long": "el name no debe exceder 255 caracteres", + "type_invalid": "el type debe ser uno de: BOOLEAN, COUNTER, VALUE", + "frequency_invalid": "la frequency debe ser una de: DAILY, WEEKLY, MONTHLY", + "specific_days_required": "specific_days es requerido para frecuencia WEEKLY", + "specific_days_invalid": "specific_days debe contener valores entre 0-6 (0=Domingo, 6=Sábado)", + "specific_dates_required": "specific_dates es requerido para frecuencia MONTHLY", + "specific_dates_invalid": "specific_dates debe contener valores entre 1-31", + "target_value_required": "target_value es requerido para tipo VALUE", + "target_value_positive": "target_value debe ser positivo" + }, + "emails": { + "verify_email_subject": "Verifica tu dirección de correo electrónico", + "verify_email_title": "¡Bienvenido! Por favor verifica tu correo electrónico", + "verify_email_body": "Gracias por registrarte. Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:", + "verify_email_link": "Verificar correo electrónico", + "verify_email_expiry": "Este enlace expirará en 24 horas.", + "verify_email_ignore": "Si no creaste una cuenta, puedes ignorar este correo de forma segura.", + "resend_verification_subject": "Verifica tu dirección de correo electrónico", + "resend_verification_title": "Verifica tu dirección de correo electrónico", + "resend_verification_body": "Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:", + "resend_verification_link": "Verificar correo electrónico", + "resend_verification_expiry": "Este enlace expirará en 24 horas.", + "resend_verification_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.", + "password_reset_subject": "Solicitud de restablecimiento de contraseña", + "password_reset_title": "Solicitud de restablecimiento de contraseña", + "password_reset_body": "Has solicitado restablecer tu contraseña. Por favor haz clic en el enlace a continuación:", + "password_reset_link": "Restablecer contraseña", + "password_reset_expiry": "Este enlace expirará en 1 hora.", + "password_reset_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.", + "welcome_subject": "¡Bienvenido a Apocapoc!", + "welcome_title": "¡Bienvenido a Apocapoc!", + "welcome_body": "Tu correo electrónico ha sido verificado exitosamente. Ya puedes comenzar a usar todas las funcionalidades de Apocapoc.", + "welcome_enjoy": "¡Disfruta construyendo mejores hábitos!" + } +} diff --git a/internal/i18n/middleware.go b/internal/i18n/middleware.go new file mode 100644 index 0000000..a306fde --- /dev/null +++ b/internal/i18n/middleware.go @@ -0,0 +1,30 @@ +package i18n + +import ( + "context" + "net/http" + + "golang.org/x/text/language" +) + +type contextKey string + +const languageKey contextKey = "language" + +func LanguageMiddleware(translator *Translator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + acceptLanguage := r.Header.Get("Accept-Language") + lang := translator.GetLanguage(acceptLanguage) + ctx := context.WithValue(r.Context(), languageKey, lang) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetLanguageFromContext(ctx context.Context) language.Tag { + if lang, ok := ctx.Value(languageKey).(language.Tag); ok { + return lang + } + return language.English +} diff --git a/internal/i18n/middleware_test.go b/internal/i18n/middleware_test.go new file mode 100644 index 0000000..e02b20d --- /dev/null +++ b/internal/i18n/middleware_test.go @@ -0,0 +1,99 @@ +package i18n + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/text/language" +) + +func TestLanguageMiddleware(t *testing.T) { + translator, _ := NewTranslator() + middleware := LanguageMiddleware(translator) + + tests := []struct { + name string + acceptLanguage string + expectedLang language.Tag + }{ + { + name: "English header", + acceptLanguage: "en-US", + expectedLang: language.English, + }, + { + name: "Spanish header", + acceptLanguage: "es-ES", + expectedLang: language.Spanish, + }, + { + name: "No header defaults to English", + acceptLanguage: "", + expectedLang: language.English, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedLang language.Tag + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedLang = GetLanguageFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := middleware(handler) + + req := httptest.NewRequest("GET", "/test", nil) + if tt.acceptLanguage != "" { + req.Header.Set("Accept-Language", tt.acceptLanguage) + } + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + if capturedLang != tt.expectedLang { + t.Errorf("Expected language %v, got %v", tt.expectedLang, capturedLang) + } + }) + } +} + +func TestGetLanguageFromContext(t *testing.T) { + tests := []struct { + name string + ctx context.Context + expected language.Tag + }{ + { + name: "Context with English", + ctx: context.WithValue(context.Background(), languageKey, language.English), + expected: language.English, + }, + { + name: "Context with Spanish", + ctx: context.WithValue(context.Background(), languageKey, language.Spanish), + expected: language.Spanish, + }, + { + name: "Context without language defaults to English", + ctx: context.Background(), + expected: language.English, + }, + { + name: "Context with wrong value type defaults to English", + ctx: context.WithValue(context.Background(), languageKey, "invalid"), + expected: language.English, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetLanguageFromContext(tt.ctx) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index 5bda0d3..05ecc43 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -9,6 +9,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/i18n" "apocapoc-api/internal/infrastructure/auth" appErrors "apocapoc-api/internal/shared/errors" ) @@ -26,6 +27,7 @@ type AuthHandlers struct { jwtService *auth.JWTService refreshTokenRepo repositories.RefreshTokenRepository refreshTokenExpiry time.Duration + translator *i18n.Translator } func NewAuthHandlers( @@ -41,6 +43,7 @@ func NewAuthHandlers( jwtService *auth.JWTService, refreshTokenRepo repositories.RefreshTokenRepository, refreshTokenExpiry time.Duration, + translator *i18n.Translator, ) *AuthHandlers { return &AuthHandlers{ registerHandler: registerHandler, @@ -55,6 +58,7 @@ func NewAuthHandlers( jwtService: jwtService, refreshTokenRepo: refreshTokenRepo, refreshTokenExpiry: refreshTokenExpiry, + translator: translator, } } @@ -104,7 +108,7 @@ type LogoutRequest struct { func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { var req RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -117,26 +121,27 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { result, err := h.registerHandler.Handle(r.Context(), cmd) if err != nil { if errors.Is(err, appErrors.ErrInvalidInput) { - respondValidationError(w, err) + respondValidationErrorI18n(w, r, h.translator, err) return } if err == appErrors.ErrAlreadyExists { - respondError(w, http.StatusConflict, "Email already registered") + respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_registered") return } if err == appErrors.ErrRegistrationClosed { - respondError(w, http.StatusForbidden, "Registration is currently closed") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "registration_closed") return } - respondError(w, http.StatusInternalServerError, "Failed to register user") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_register_user") return } + lang := i18n.GetLanguageFromContext(r.Context()) var message string if result.EmailVerificationRequired { - message = "Registration successful. Please check your email to verify your account." + message = h.translator.Success(lang, "registration_with_verification") } else { - message = "Registration successful. You can now login." + message = h.translator.Success(lang, "registration_without_verification") } respondJSON(w, http.StatusCreated, RegisterResponse{ @@ -161,7 +166,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -173,31 +178,31 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { result, err := h.loginHandler.Handle(r.Context(), query) if err != nil { if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput { - respondError(w, http.StatusUnauthorized, "Invalid email or password") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_credentials") return } if err == appErrors.ErrEmailNotVerified { - respondError(w, http.StatusForbidden, "Please verify your email before logging in") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified") return } - respondError(w, http.StatusInternalServerError, "Failed to login") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_login") return } token, err := h.jwtService.GenerateToken(result.UserID, result.Email) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to generate token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token") return } refreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to create refresh token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token") return } if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil { - respondError(w, http.StatusInternalServerError, "Failed to save refresh token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token") return } @@ -223,7 +228,7 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -234,27 +239,27 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) { result, err := h.refreshTokenHandler.Handle(r.Context(), query) if err != nil { if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput { - respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_expired_refresh_token") return } - respondError(w, http.StatusInternalServerError, "Failed to refresh token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token") return } token, err := h.jwtService.GenerateToken(result.UserID, result.Email) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to generate token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token") return } newRefreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to create refresh token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token") return } if err := h.refreshTokenRepo.Create(r.Context(), newRefreshToken); err != nil { - respondError(w, http.StatusInternalServerError, "Failed to save refresh token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token") return } @@ -283,7 +288,7 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { var req LogoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -294,19 +299,20 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { err := h.revokeTokenHandler.Handle(r.Context(), cmd) if err != nil { if err == appErrors.ErrNotFound { - respondError(w, http.StatusNotFound, "Refresh token not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "refresh_token_not_found") return } if err == appErrors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid refresh token") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_refresh_token") return } - respondError(w, http.StatusInternalServerError, "Failed to revoke token") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Successfully logged out", + "message": h.translator.Success(lang, "logged_out"), }) } @@ -333,7 +339,7 @@ type ResendVerificationRequest struct { func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { var req VerifyEmailRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -344,19 +350,20 @@ func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { err := h.verifyEmailHandler.Handle(r.Context(), cmd) if err != nil { if err == appErrors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid or expired verification token") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_expired_verification_token") return } if err == appErrors.ErrAlreadyExists { - respondError(w, http.StatusConflict, "Email already verified") + respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified") return } - respondError(w, http.StatusInternalServerError, "Failed to verify email") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_verify_email") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Email verified successfully", + "message": h.translator.Success(lang, "email_verified"), }) } @@ -376,7 +383,7 @@ func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) ResendVerification(w http.ResponseWriter, r *http.Request) { var req ResendVerificationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -387,23 +394,24 @@ func (h *AuthHandlers) ResendVerification(w http.ResponseWriter, r *http.Request err := h.resendVerificationEmailHandler.Handle(r.Context(), cmd) if err != nil { if err == appErrors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid email") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email") return } if err == appErrors.ErrNotFound { - respondError(w, http.StatusNotFound, "User not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found") return } if err == appErrors.ErrAlreadyExists { - respondError(w, http.StatusConflict, "Email already verified") + respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified") return } - respondError(w, http.StatusInternalServerError, "Failed to send verification email") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_verification_email") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Verification email sent successfully", + "message": h.translator.Success(lang, "verification_email_sent"), }) } @@ -432,7 +440,7 @@ type ResetPasswordRequest struct { func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { var req ForgotPasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -443,23 +451,24 @@ func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { err := h.requestPasswordResetHandler.Handle(r.Context(), cmd) if err != nil { if err == appErrors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid email") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email") return } if err == appErrors.ErrNotFound { - respondError(w, http.StatusNotFound, "User not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found") return } if err == appErrors.ErrEmailNotVerified { - respondError(w, http.StatusForbidden, "Please verify your email before resetting password") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified_reset") return } - respondError(w, http.StatusInternalServerError, "Failed to send reset email") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_reset_email") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Password reset email sent successfully", + "message": h.translator.Success(lang, "password_reset_email_sent"), }) } @@ -478,7 +487,7 @@ func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) { var req ResetPasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -490,18 +499,19 @@ func (h *AuthHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) { err := h.resetPasswordHandler.Handle(r.Context(), cmd) if err != nil { if err == appErrors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid or expired token, or password requirements not met") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_token_or_password") return } if err == appErrors.ErrNotFound { - respondError(w, http.StatusNotFound, "User not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found") return } - respondError(w, http.StatusInternalServerError, "Failed to reset password") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_reset_password") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Password reset successfully", + "message": h.translator.Success(lang, "password_reset"), }) } diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index 28448f2..de098b2 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -55,14 +55,14 @@ type TodaysHabitEntryResponse struct { } type TodaysHabitResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Type value_objects.HabitType `json:"type"` - TargetValue *float64 `json:"target_value,omitempty"` - IsNegative bool `json:"is_negative"` - ScheduledDate time.Time `json:"scheduled_date"` - IsCarriedOver bool `json:"is_carried_over"` - Entry *TodaysHabitEntryResponse `json:"entry,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Type value_objects.HabitType `json:"type"` + TargetValue *float64 `json:"target_value,omitempty"` + IsNegative bool `json:"is_negative"` + ScheduledDate time.Time `json:"scheduled_date"` + IsCarriedOver bool `json:"is_carried_over"` + Entry *TodaysHabitEntryResponse `json:"entry,omitempty"` } type UserHabitResponse struct { diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 34b88e0..547656b 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -10,6 +10,7 @@ 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" "github.com/go-chi/chi/v5" @@ -26,6 +27,7 @@ type HabitHandlers struct { markHandler *commands.MarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler userRepo repositories.UserRepository + translator *i18n.Translator } func NewHabitHandlers( @@ -39,6 +41,7 @@ func NewHabitHandlers( markHandler *commands.MarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler, userRepo repositories.UserRepository, + translator *i18n.Translator, ) *HabitHandlers { return &HabitHandlers{ createHandler: createHandler, @@ -51,6 +54,7 @@ func NewHabitHandlers( markHandler: markHandler, unmarkHandler: unmarkHandler, userRepo: userRepo, + translator: translator, } } @@ -70,13 +74,13 @@ func NewHabitHandlers( func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { var req CreateHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -99,7 +103,7 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, err.Error()) return } - respondError(w, http.StatusInternalServerError, "Failed to create habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit") return } @@ -119,7 +123,7 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -129,7 +133,7 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { habits, err := h.getUserHabitsHandler.Handle(r.Context(), query) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to get habits") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits") return } @@ -168,7 +172,7 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -180,14 +184,14 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) { habit, err := h.getHabitByIDHandler.Handle(r.Context(), query) if err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } - respondError(w, http.StatusInternalServerError, "Failed to get habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit") return } @@ -226,13 +230,13 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } var req UpdateHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } @@ -249,18 +253,18 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { if err := h.updateHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } if err == errors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid input") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input") return } - respondError(w, http.StatusInternalServerError, "Failed to update habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_update_habit") return } @@ -285,7 +289,7 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -296,14 +300,14 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) { if err := h.archiveHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } - respondError(w, http.StatusInternalServerError, "Failed to archive habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_archive_habit") return } @@ -333,7 +337,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -345,7 +349,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) if fromStr := r.URL.Query().Get("from"); fromStr != "" { from, err := time.Parse("2006-01-02", fromStr) if err != nil { - respondError(w, http.StatusBadRequest, "Invalid 'from' date format (use YYYY-MM-DD)") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_from_date_format") return } query.From = &from @@ -354,7 +358,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) if toStr := r.URL.Query().Get("to"); toStr != "" { to, err := time.Parse("2006-01-02", toStr) if err != nil { - respondError(w, http.StatusBadRequest, "Invalid 'to' date format (use YYYY-MM-DD)") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_to_date_format") return } query.To = &to @@ -375,7 +379,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) if pageStr := r.URL.Query().Get("page"); pageStr != "" { page, err := strconv.Atoi(pageStr) if err != nil || page < 1 { - respondError(w, http.StatusBadRequest, "Invalid 'page' parameter") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_page_parameter") return } query.Page = page @@ -386,7 +390,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 || limit > 100 { - respondError(w, http.StatusBadRequest, "Invalid 'limit' parameter (must be 1-100)") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_limit_parameter") return } query.Limit = limit @@ -402,14 +406,14 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) result, err := h.getHabitEntriesHandler.Handle(r.Context(), query) if err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } - respondError(w, http.StatusInternalServerError, "Failed to get habit entries") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit_entries") return } @@ -447,13 +451,13 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } user, err := h.userRepo.FindByID(r.Context(), userID) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to get user") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user") return } @@ -473,7 +477,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) habits, err := h.getTodaysHandler.Handle(r.Context(), query) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to get habits") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits") return } @@ -523,13 +527,13 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) { var req MarkHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "Invalid request body") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body") return } scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate) if err != nil { - respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format") return } @@ -541,14 +545,14 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) { if err := h.markHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrAlreadyExists { - respondError(w, http.StatusConflict, "Habit already marked for this date") + respondErrorI18n(w, r, h.translator, http.StatusConflict, "habit_already_marked") return } if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } - respondError(w, http.StatusInternalServerError, "Failed to mark habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_mark_habit") return } @@ -576,13 +580,13 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } scheduledDate, err := time.Parse("2006-01-02", dateStr) if err != nil { - respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)") + respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format") return } @@ -594,14 +598,14 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) { if err := h.unmarkHandler.Handle(r.Context(), cmd); err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit entry not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_entry_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } - respondError(w, http.StatusInternalServerError, "Failed to unmark habit") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_unmark_habit") return } diff --git a/internal/infrastructure/http/i18n_helper.go b/internal/infrastructure/http/i18n_helper.go new file mode 100644 index 0000000..a725d4b --- /dev/null +++ b/internal/infrastructure/http/i18n_helper.go @@ -0,0 +1,52 @@ +package http + +import ( + "net/http" + "strings" + + "apocapoc-api/internal/i18n" + + "golang.org/x/text/language" +) + +func respondErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, status int, key string) { + lang := i18n.GetLanguageFromContext(r.Context()) + message := translator.Error(lang, key) + respondJSON(w, status, ErrorResponse{Error: message}) +} + +func respondSuccessI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, key string) { + lang := i18n.GetLanguageFromContext(r.Context()) + message := translator.Success(lang, key) + respondJSON(w, http.StatusOK, map[string]string{"message": message}) +} + +func respondValidationErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, err error) { + lang := i18n.GetLanguageFromContext(r.Context()) + errMsg := err.Error() + var field string + var translatedMsg string + + if strings.Contains(errMsg, ": ") { + parts := strings.SplitN(errMsg, ": ", 3) + if len(parts) >= 3 { + field = parts[1] + validationKey := parts[2] + translatedMsg = translator.Validation(lang, validationKey) + respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{ + Error: translatedMsg, + Field: field, + }) + return + } + } + + respondJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: errMsg, + }) +} + +func getLanguageFromRequest(r *http.Request, translator *i18n.Translator) language.Tag { + lang := i18n.GetLanguageFromContext(r.Context()) + return lang +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 4317b45..693e2dc 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -11,6 +11,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/i18n" "apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/crypto" "apocapoc-api/internal/infrastructure/persistence/sqlite" @@ -66,13 +67,15 @@ func setupTestServer(t *testing.T) *TestServer { deleteUserHandler := commands.NewDeleteUserHandler(userRepo) - authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) - habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo) - statsHandlers := NewStatsHandlers(getHabitStatsHandler) - healthHandlers := NewHealthHandlers(db) - userHandlers := NewUserHandlers(deleteUserHandler) + translator, _ := i18n.NewTranslator() - router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService) + 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) + statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator) + healthHandlers := NewHealthHandlers(db) + userHandlers := NewUserHandlers(deleteUserHandler, translator) + + router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator) handler := http.Handler(router) return &TestServer{ diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 277eba5..6b972fd 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "apocapoc-api/internal/i18n" "apocapoc-api/internal/infrastructure/auth" "github.com/go-chi/chi/v5" @@ -15,15 +16,16 @@ import ( _ "apocapoc-api/docs" ) -func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, jwtService *auth.JWTService) *chi.Mux { +func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(i18n.LanguageMiddleware(translator)) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{appURL}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Accept-Language"}, AllowCredentials: true, })) diff --git a/internal/infrastructure/http/stats_handlers.go b/internal/infrastructure/http/stats_handlers.go index d85bdec..cd49d9b 100644 --- a/internal/infrastructure/http/stats_handlers.go +++ b/internal/infrastructure/http/stats_handlers.go @@ -6,18 +6,22 @@ import ( "apocapoc-api/internal/application/queries" "apocapoc-api/internal/shared/errors" + "apocapoc-api/internal/i18n" "github.com/go-chi/chi/v5" ) type StatsHandlers struct { getHabitStatsHandler *queries.GetHabitStatsHandler + translator *i18n.Translator } func NewStatsHandlers( getHabitStatsHandler *queries.GetHabitStatsHandler, + translator *i18n.Translator, ) *StatsHandlers { return &StatsHandlers{ getHabitStatsHandler: getHabitStatsHandler, + translator: translator, } } @@ -39,7 +43,7 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { - respondError(w, http.StatusUnauthorized, "User not authenticated") + respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated") return } @@ -51,14 +55,14 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) { stats, err := h.getHabitStatsHandler.Handle(r.Context(), query) if err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "Habit not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found") return } if err == errors.ErrUnauthorized { - respondError(w, http.StatusForbidden, "Access denied") + respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied") return } - respondError(w, http.StatusInternalServerError, "Failed to get habit stats") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_stats") return } diff --git a/internal/infrastructure/http/user_handlers.go b/internal/infrastructure/http/user_handlers.go index cc34f58..7c931e7 100644 --- a/internal/infrastructure/http/user_handlers.go +++ b/internal/infrastructure/http/user_handlers.go @@ -4,16 +4,19 @@ import ( "net/http" "apocapoc-api/internal/application/commands" + "apocapoc-api/internal/i18n" "apocapoc-api/internal/shared/errors" ) type UserHandlers struct { deleteUserHandler *commands.DeleteUserHandler + translator *i18n.Translator } -func NewUserHandlers(deleteUserHandler *commands.DeleteUserHandler) *UserHandlers { +func NewUserHandlers(deleteUserHandler *commands.DeleteUserHandler, translator *i18n.Translator) *UserHandlers { return &UserHandlers{ deleteUserHandler: deleteUserHandler, + translator: translator, } } @@ -38,14 +41,15 @@ func (h *UserHandlers) DeleteAccount(w http.ResponseWriter, r *http.Request) { err := h.deleteUserHandler.Handle(r.Context(), cmd) if err != nil { if err == errors.ErrNotFound { - respondError(w, http.StatusNotFound, "User not found") + respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found") return } - respondError(w, http.StatusInternalServerError, "Failed to delete account") + respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_delete_user") return } + lang := i18n.GetLanguageFromContext(r.Context()) respondJSON(w, http.StatusOK, map[string]string{ - "message": "Account deleted successfully", + "message": h.translator.Success(lang, "user_deleted"), }) }