From c17e85c14e3c7651920db300981ba217b75b0f10 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 2 Dec 2025 02:01:47 -0600 Subject: [PATCH] Add comprehensive i18n localization support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add go-i18n package for internationalization - Create i18n middleware to extract Accept-Language header - Add translation files for en, es, fr, de, pt languages - Localize all handler error messages and responses - Add language context to all API handlers Supported languages: English, Spanish, French, German, Portuguese 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/api/main.go | 8 + docs/# Full Localization Plan for Casera.md | 412 ++++++++++++++++++++ go.mod | 1 + go.sum | 2 + internal/handlers/auth_handler.go | 81 ++-- internal/handlers/contractor_handler.go | 39 +- internal/handlers/document_handler.go | 49 +-- internal/handlers/notification_handler.go | 17 +- internal/handlers/residence_handler.go | 65 +-- internal/handlers/static_data_handler.go | 15 +- internal/handlers/subscription_handler.go | 13 +- internal/handlers/task_handler.go | 103 ++--- internal/handlers/upload_handler.go | 9 +- internal/handlers/user_handler.go | 5 +- internal/i18n/i18n.go | 86 ++++ internal/i18n/middleware.go | 122 ++++++ internal/i18n/translations/de.json | 187 +++++++++ internal/i18n/translations/en.json | 187 +++++++++ internal/i18n/translations/es.json | 187 +++++++++ internal/i18n/translations/fr.json | 187 +++++++++ internal/i18n/translations/pt.json | 187 +++++++++ internal/router/router.go | 2 + 22 files changed, 1771 insertions(+), 193 deletions(-) create mode 100644 docs/# Full Localization Plan for Casera.md create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/middleware.go create mode 100644 internal/i18n/translations/de.json create mode 100644 internal/i18n/translations/en.json create mode 100644 internal/i18n/translations/es.json create mode 100644 internal/i18n/translations/fr.json create mode 100644 internal/i18n/translations/pt.json diff --git a/cmd/api/main.go b/cmd/api/main.go index 34df1b2..f59f862 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/database" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/router" "github.com/treytartt/casera-api/internal/services" @@ -31,6 +32,13 @@ func main() { // Initialize logger utils.InitLogger(cfg.Server.Debug) + // Initialize i18n + if err := i18n.Init(); err != nil { + log.Warn().Err(err).Msg("Failed to initialize i18n - using English only") + } else { + log.Info().Strs("languages", i18n.SupportedLanguages).Msg("i18n initialized") + } + log.Info(). Bool("debug", cfg.Server.Debug). Int("port", cfg.Server.Port). diff --git a/docs/# Full Localization Plan for Casera.md b/docs/# Full Localization Plan for Casera.md new file mode 100644 index 0000000..3e3c369 --- /dev/null +++ b/docs/# Full Localization Plan for Casera.md @@ -0,0 +1,412 @@ +# Full Localization Plan for Casera + +## Overview + +Complete localization of the Casera property management app across three codebases: +- **Go API** - Server-side localization of errors, emails, push notifications, lookup data +- **KMM/Android** - Compose Multiplatform string resources +- **iOS** - Apple String Catalogs (.xcstrings) + +**Target Languages**: English (base), Spanish, French, German, Portuguese (extensible) + +**Key Decisions**: +- API returns localized strings (server-side translation) +- Accept-Language header determines locale +- Clients display what API returns for API content +- Clients localize their own UI strings + +--- + +## Part 1: Go API Localization + +### 1.1 Add Dependency + +```bash +go get github.com/nicksnyder/go-i18n/v2 +``` + +### 1.2 Directory Structure + +``` +myCribAPI-go/ +├── internal/ +│ └── i18n/ +│ ├── i18n.go # Core setup, bundle, T() helper +│ ├── middleware.go # Gin middleware for Accept-Language +│ └── translations/ +│ ├── en.json # English (base) +│ ├── es.json # Spanish +│ ├── fr.json # French +│ ├── de.json # German +│ └── pt.json # Portuguese +├── templates/ +│ └── emails/ +│ ├── en/ +│ │ ├── welcome.html +│ │ ├── verification.html +│ │ └── password_reset.html +│ ├── es/ +│ ├── fr/ +│ ├── de/ +│ └── pt/ +``` + +### 1.3 Core Files to Create + +**internal/i18n/i18n.go**: +- Initialize i18n bundle with embedded translation files +- Provide `T(localizer, messageID, data)` helper function +- Load all JSON translation files at startup + +**internal/i18n/middleware.go**: +- Parse Accept-Language header +- Match against supported languages (en, es, fr, de, pt) +- Store localizer in Gin context +- Provide `GetLocalizer(c)` helper + +### 1.4 Translation File Format + +```json +{ + "error.invalid_credentials": "Invalid credentials", + "error.username_taken": "Username already taken", + "error.email_taken": "Email already registered", + "error.not_authenticated": "Not authenticated", + "error.task_not_found": "Task not found", + "error.residence_access_denied": "You don't have access to this property", + "message.logged_out": "Logged out successfully", + "message.task_deleted": "Task deleted successfully", + "push.task_due_soon.title": "Task Due Soon", + "push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}" +} +``` + +### 1.5 Handler Update Pattern + +```go +// Before +c.JSON(400, gin.H{"error": "Invalid credentials"}) + +// After +localizer := i18n.GetLocalizer(c) +c.JSON(400, gin.H{"error": i18n.T(localizer, "error.invalid_credentials", nil)}) +``` + +### 1.6 Database for Lookup Translations + +Add translation table for task categories, priorities, statuses, frequencies: + +```sql +CREATE TABLE lookup_translations ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(50) NOT NULL, + record_id INT NOT NULL, + locale VARCHAR(10) NOT NULL, + field_name VARCHAR(50) NOT NULL, + translated_value TEXT NOT NULL, + UNIQUE(table_name, record_id, locale, field_name) +); +``` + +Update lookup endpoints to return translated names based on locale. + +### 1.7 Critical Go Files to Modify + +| File | Action | Description | +|------|--------|-------------| +| `internal/i18n/i18n.go` | CREATE | Core i18n bundle and helpers | +| `internal/i18n/middleware.go` | CREATE | Locale detection middleware | +| `internal/i18n/translations/*.json` | CREATE | Translation files (5 languages) | +| `internal/router/router.go` | MODIFY | Add i18n middleware | +| `internal/handlers/auth_handler.go` | MODIFY | Localize ~22 error strings | +| `internal/handlers/task_handler.go` | MODIFY | Localize ~30 error strings | +| `internal/handlers/residence_handler.go` | MODIFY | Localize ~20 error strings | +| `internal/handlers/contractor_handler.go` | MODIFY | Localize ~15 error strings | +| `internal/handlers/document_handler.go` | MODIFY | Localize ~15 error strings | +| `internal/services/email_service.go` | MODIFY | Template-based emails | +| `internal/services/notification_service.go` | MODIFY | Localized push content | +| `internal/services/lookup_service.go` | MODIFY | Return translated lookups | +| `templates/emails/**` | CREATE | Email templates per language | + +--- + +## Part 2: KMM/Android Localization + +### 2.1 Strategy + +Use Compose Multiplatform Resources (already in build.gradle.kts via `compose.components.resources`). + +### 2.2 Directory Structure + +``` +MyCribKMM/composeApp/src/commonMain/ +└── composeResources/ + ├── values/ + │ └── strings.xml # English (base) + ├── values-es/ + │ └── strings.xml # Spanish + ├── values-fr/ + │ └── strings.xml # French + ├── values-de/ + │ └── strings.xml # German + └── values-pt/ + └── strings.xml # Portuguese +``` + +### 2.3 String Resource Format + +```xml + + + + Sign In + Manage your properties with ease + Username or Email + Password + Sign In + Forgot Password? + + + My Properties + No properties yet + Add your first property to get started! + Add Property + + + Tasks + Add New Task + Overdue + Due Soon + + + Save + Cancel + Delete + Loading… + Something went wrong. Please try again. + + + Back + Close + Add Property + +``` + +### 2.4 Usage in Compose + +```kotlin +import casera.composeapp.generated.resources.Res +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun LoginScreen() { + Text(stringResource(Res.string.auth_login_title)) + Button(onClick = { /* ... */ }) { + Text(stringResource(Res.string.auth_login_button)) + } +} +``` + +### 2.5 Critical KMM Files to Modify + +| File | Action | Description | +|------|--------|-------------| +| `composeResources/values/strings.xml` | CREATE | Base English strings (~500) | +| `composeResources/values-es/strings.xml` | CREATE | Spanish translations | +| `composeResources/values-fr/strings.xml` | CREATE | French translations | +| `composeResources/values-de/strings.xml` | CREATE | German translations | +| `composeResources/values-pt/strings.xml` | CREATE | Portuguese translations | +| `ui/screens/LoginScreen.kt` | MODIFY | Replace hardcoded strings | +| `ui/screens/ResidencesScreen.kt` | MODIFY | Replace hardcoded strings | +| `ui/screens/TasksScreen.kt` | MODIFY | Replace hardcoded strings | +| `ui/screens/ContractorsScreen.kt` | MODIFY | Replace hardcoded strings | +| `ui/screens/DocumentsScreen.kt` | MODIFY | Replace hardcoded strings | +| `ui/components/*.kt` | MODIFY | Replace hardcoded strings (33 files) | +| All other screen files | MODIFY | Replace hardcoded strings | + +--- + +## Part 3: iOS Localization + +### 3.1 Strategy + +Use Apple String Catalogs (`.xcstrings`) - modern approach with Xcode visual editor. + +### 3.2 Create String Catalog + +1. Xcode: File > New > File > String Catalog +2. Name: `Localizable.xcstrings` +3. Add languages: English, Spanish, French, German, Portuguese + +### 3.3 Type-Safe String Access + +Create `iosApp/iosApp/Helpers/L10n.swift`: + +```swift +import Foundation + +enum L10n { + enum Auth { + static let loginTitle = String(localized: "auth_login_title") + static let loginSubtitle = String(localized: "auth_login_subtitle") + static let loginUsernameLabel = String(localized: "auth_login_username_label") + static let loginPasswordLabel = String(localized: "auth_login_password_label") + static let loginButton = String(localized: "auth_login_button") + static let forgotPassword = String(localized: "auth_forgot_password") + } + + enum Properties { + static let title = String(localized: "properties_title") + static let emptyTitle = String(localized: "properties_empty_title") + static let emptySubtitle = String(localized: "properties_empty_subtitle") + static let addButton = String(localized: "properties_add_button") + } + + enum Tasks { + static let title = String(localized: "tasks_title") + static let addTitle = String(localized: "tasks_add_title") + static let columnOverdue = String(localized: "tasks_column_overdue") + static let columnDueSoon = String(localized: "tasks_column_due_soon") + } + + enum Common { + static let save = String(localized: "common_save") + static let cancel = String(localized: "common_cancel") + static let delete = String(localized: "common_delete") + static let loading = String(localized: "common_loading") + static let errorGeneric = String(localized: "common_error_generic") + } +} +``` + +### 3.4 Usage in SwiftUI + +```swift +// Before +Text("My Properties") + .navigationTitle("My Properties") + +// After +Text(L10n.Properties.title) + .navigationTitle(L10n.Properties.title) +``` + +### 3.5 Critical iOS Files to Modify + +| File | Action | Description | +|------|--------|-------------| +| `iosApp/Localizable.xcstrings` | CREATE | String catalog with all translations | +| `iosApp/Helpers/L10n.swift` | CREATE | Type-safe string access | +| `Login/LoginView.swift` | MODIFY | Replace ~7 hardcoded strings | +| `Login/LoginViewModel.swift` | MODIFY | Replace ~12 error messages | +| `Register/RegisterView.swift` | MODIFY | Replace ~10 hardcoded strings | +| `Residence/*.swift` | MODIFY | Replace ~30 hardcoded strings | +| `Task/*.swift` | MODIFY | Replace ~50 hardcoded strings | +| `Contractor/*.swift` | MODIFY | Replace ~20 hardcoded strings | +| `Document/*.swift` | MODIFY | Replace ~15 hardcoded strings | +| `Profile/*.swift` | MODIFY | Replace ~20 hardcoded strings | +| All other view files | MODIFY | Replace hardcoded strings | + +--- + +## Part 4: Implementation Order + +### Phase 1: Infrastructure (Do First) + +1. **Go API**: + - Add go-i18n dependency + - Create `internal/i18n/` package with i18n.go and middleware.go + - Create base `en.json` with all extractable strings + - Add middleware to router + - Test with curl using Accept-Language header + +2. **KMM**: + - Create `composeResources/values/strings.xml` with ~50 core strings + - Verify Compose resources compile correctly + - Update one screen (LoginScreen) as proof of concept + +3. **iOS**: + - Create `Localizable.xcstrings` in Xcode + - Create `L10n.swift` helper + - Add ~50 core strings + - Update LoginView as proof of concept + +### Phase 2: API Full Localization + +1. Update all handlers to use localized errors +2. Add lookup_translations table and seed data +3. Update lookup service to return translated names +4. Move email templates to files, create Spanish versions +5. Update push notification service for localized content + +### Phase 3: Mobile String Extraction + +**Order by feature (same for KMM and iOS)**: +1. Auth screens (Login, Register, Verify, Password Reset, Apple Sign In) +2. Property screens (List, Detail, Form, Join, Share, Manage Users) +3. Task screens (List, Detail, Form, Complete, Actions, Kanban) +4. Contractor screens (List, Detail, Form) +5. Document screens (List, Detail, Form, Warranties) +6. Profile screens (Profile, Settings, Notifications) +7. Common components (Dialogs, Cards, Empty States, Loading) + +### Phase 4: Create Translation Files + +- Create es.json, fr.json, de.json, pt.json for Go API +- Create values-es/, values-fr/, values-de/, values-pt/ for KMM +- Add all language translations to iOS String Catalog + +### Phase 5: Testing & Polish + +- Test all screens in each language +- Verify email rendering in each language +- Test push notifications +- Verify lookup data translations +- Handle edge cases (long strings, RTL future-proofing) + +--- + +## String Naming Convention + +Use consistent keys across all platforms: + +``` +__ + +Examples: +auth_login_title +auth_login_button +properties_list_empty_title +properties_form_field_name +tasks_detail_button_complete +common_button_save +common_button_cancel +error_network_timeout +a11y_button_back +``` + + +--- + +## Estimated String Counts + +| Platform | Approximate Strings | +|----------|---------------------| +| Go API - Errors | ~100 | +| Go API - Email templates | ~50 per language | +| Go API - Push notifications | ~20 | +| Go API - Lookup data | ~50 | +| KMM/Android | ~500 | +| iOS | ~500 | +| **Total unique strings** | ~700-800 | + +--- + +## Translation Workflow + +1. **Extract**: All English strings defined first +2. **Export**: JSON (Go), XML (Android), .xcstrings (iOS) +3. **Translate**: Use Lokalise, Crowdin, or manual translation +4. **Import**: Place translated files in correct locations +5. **Test**: Verify in each language diff --git a/go.mod b/go.mod index 12c26e7..45f646a 100644 --- a/go.mod +++ b/go.mod @@ -79,6 +79,7 @@ require ( github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 22e9a3f..2475f72 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go index 9c2f8fa..02458ad 100644 --- a/internal/handlers/auth_handler.go +++ b/internal/handlers/auth_handler.go @@ -9,6 +9,7 @@ import ( "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/dto/responses" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/services" ) @@ -40,7 +41,7 @@ func (h *AuthHandler) Login(c *gin.Context) { var req requests.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -51,10 +52,10 @@ func (h *AuthHandler) Login(c *gin.Context) { response, err := h.authService.Login(&req) if err != nil { status := http.StatusUnauthorized - message := "Invalid credentials" + message := i18n.LocalizedMessage(c, "error.invalid_credentials") if errors.Is(err, services.ErrUserInactive) { - message = "Account is inactive" + message = i18n.LocalizedMessage(c, "error.account_inactive") } log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed") @@ -70,7 +71,7 @@ func (h *AuthHandler) Register(c *gin.Context) { var req requests.RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -84,12 +85,12 @@ func (h *AuthHandler) Register(c *gin.Context) { message := err.Error() if errors.Is(err, services.ErrUsernameTaken) { - message = "Username already taken" + message = i18n.LocalizedMessage(c, "error.username_taken") } else if errors.Is(err, services.ErrEmailTaken) { - message = "Email already registered" + message = i18n.LocalizedMessage(c, "error.email_taken") } else { status = http.StatusInternalServerError - message = "Registration failed" + message = i18n.LocalizedMessage(c, "error.registration_failed") log.Error().Err(err).Msg("Registration failed") } @@ -113,7 +114,7 @@ func (h *AuthHandler) Register(c *gin.Context) { func (h *AuthHandler) Logout(c *gin.Context) { token := middleware.GetAuthToken(c) if token == "" { - c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: "Not authenticated"}) + c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.not_authenticated")}) return } @@ -129,7 +130,7 @@ func (h *AuthHandler) Logout(c *gin.Context) { } } - c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"}) + c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.logged_out")}) } // CurrentUser handles GET /api/auth/me/ @@ -142,7 +143,7 @@ func (h *AuthHandler) CurrentUser(c *gin.Context) { response, err := h.authService.GetCurrentUser(user.ID) if err != nil { log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user") - c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to get user"}) + c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_get_user")}) return } @@ -159,7 +160,7 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) { var req requests.UpdateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -170,12 +171,12 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) { response, err := h.authService.UpdateProfile(user.ID, &req) if err != nil { if errors.Is(err, services.ErrEmailTaken) { - c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: "Email already taken"}) + c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_taken")}) return } log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile") - c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to update profile"}) + c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_update_profile")}) return } @@ -192,7 +193,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { var req requests.VerifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -206,14 +207,14 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { message := err.Error() if errors.Is(err, services.ErrInvalidCode) { - message = "Invalid verification code" + message = i18n.LocalizedMessage(c, "error.invalid_verification_code") } else if errors.Is(err, services.ErrCodeExpired) { - message = "Verification code has expired" + message = i18n.LocalizedMessage(c, "error.verification_code_expired") } else if errors.Is(err, services.ErrAlreadyVerified) { - message = "Email already verified" + message = i18n.LocalizedMessage(c, "error.email_already_verified") } else { status = http.StatusInternalServerError - message = "Verification failed" + message = i18n.LocalizedMessage(c, "error.verification_failed") log.Error().Err(err).Uint("user_id", user.ID).Msg("Email verification failed") } @@ -222,7 +223,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { } c.JSON(http.StatusOK, responses.VerifyEmailResponse{ - Message: "Email verified successfully", + Message: i18n.LocalizedMessage(c, "message.email_verified"), Verified: true, }) } @@ -237,12 +238,12 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) { code, err := h.authService.ResendVerificationCode(user.ID) if err != nil { if errors.Is(err, services.ErrAlreadyVerified) { - c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: "Email already verified"}) + c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_verified")}) return } log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification") - c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to resend verification"}) + c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_resend_verification")}) return } @@ -255,7 +256,7 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) { }() } - c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"}) + c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.verification_email_sent")}) } // ForgotPassword handles POST /api/auth/forgot-password/ @@ -263,7 +264,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { var req requests.ForgotPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -275,7 +276,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { if err != nil { if errors.Is(err, services.ErrRateLimitExceeded) { c.JSON(http.StatusTooManyRequests, responses.ErrorResponse{ - Error: "Too many password reset requests. Please try again later.", + Error: i18n.LocalizedMessage(c, "error.rate_limit_exceeded"), }) return } @@ -295,7 +296,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { // Always return success to prevent email enumeration c.JSON(http.StatusOK, responses.ForgotPasswordResponse{ - Message: "If an account with that email exists, a password reset code has been sent.", + Message: i18n.LocalizedMessage(c, "message.password_reset_email_sent"), }) } @@ -304,7 +305,7 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) { var req requests.VerifyResetCodeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -315,13 +316,13 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) { resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code) if err != nil { status := http.StatusBadRequest - message := "Invalid verification code" + message := i18n.LocalizedMessage(c, "error.invalid_verification_code") if errors.Is(err, services.ErrCodeExpired) { - message = "Verification code has expired" + message = i18n.LocalizedMessage(c, "error.verification_code_expired") } else if errors.Is(err, services.ErrRateLimitExceeded) { status = http.StatusTooManyRequests - message = "Too many attempts. Please request a new code." + message = i18n.LocalizedMessage(c, "error.too_many_attempts") } c.JSON(status, responses.ErrorResponse{Error: message}) @@ -329,7 +330,7 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) { } c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{ - Message: "Code verified successfully", + Message: i18n.LocalizedMessage(c, "message.reset_code_verified"), ResetToken: resetToken, }) } @@ -339,7 +340,7 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { var req requests.ResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -350,13 +351,13 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { err := h.authService.ResetPassword(req.ResetToken, req.NewPassword) if err != nil { status := http.StatusBadRequest - message := "Invalid or expired reset token" + message := i18n.LocalizedMessage(c, "error.invalid_reset_token") if errors.Is(err, services.ErrInvalidResetToken) { - message = "Invalid or expired reset token" + message = i18n.LocalizedMessage(c, "error.invalid_reset_token") } else { status = http.StatusInternalServerError - message = "Password reset failed" + message = i18n.LocalizedMessage(c, "error.password_reset_failed") log.Error().Err(err).Msg("Password reset failed") } @@ -365,7 +366,7 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) { } c.JSON(http.StatusOK, responses.ResetPasswordResponse{ - Message: "Password reset successfully. Please log in with your new password.", + Message: i18n.LocalizedMessage(c, "message.password_reset_success"), }) } @@ -374,7 +375,7 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) { var req requests.AppleSignInRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ - Error: "Invalid request body", + Error: i18n.LocalizedMessage(c, "error.invalid_request_body"), Details: map[string]string{ "validation": err.Error(), }, @@ -385,7 +386,7 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) { if h.appleAuthService == nil { log.Error().Msg("Apple auth service not configured") c.JSON(http.StatusInternalServerError, responses.ErrorResponse{ - Error: "Apple Sign In is not configured", + Error: i18n.LocalizedMessage(c, "error.apple_signin_not_configured"), }) return } @@ -393,12 +394,12 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) { response, err := h.authService.AppleSignIn(c.Request.Context(), h.appleAuthService, &req) if err != nil { status := http.StatusUnauthorized - message := "Apple Sign In failed" + message := i18n.LocalizedMessage(c, "error.apple_signin_failed") if errors.Is(err, services.ErrUserInactive) { - message = "Account is inactive" + message = i18n.LocalizedMessage(c, "error.account_inactive") } else if errors.Is(err, services.ErrAppleSignInFailed) { - message = "Invalid Apple identity token" + message = i18n.LocalizedMessage(c, "error.invalid_apple_token") } log.Debug().Err(err).Msg("Apple Sign In failed") diff --git a/internal/handlers/contractor_handler.go b/internal/handlers/contractor_handler.go index 28b3039..fad769a 100644 --- a/internal/handlers/contractor_handler.go +++ b/internal/handlers/contractor_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/treytartt/casera-api/internal/dto/requests" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -39,7 +40,7 @@ func (h *ContractorHandler) GetContractor(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")}) return } @@ -47,9 +48,9 @@ func (h *ContractorHandler) GetContractor(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrContractorNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")}) case errors.Is(err, services.ErrContractorAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -70,7 +71,7 @@ func (h *ContractorHandler) CreateContractor(c *gin.Context) { response, err := h.contractorService.CreateContractor(&req, user.ID) if err != nil { if errors.Is(err, services.ErrResidenceAccessDenied) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -84,7 +85,7 @@ func (h *ContractorHandler) UpdateContractor(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")}) return } @@ -98,9 +99,9 @@ func (h *ContractorHandler) UpdateContractor(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrContractorNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")}) case errors.Is(err, services.ErrContractorAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -114,7 +115,7 @@ func (h *ContractorHandler) DeleteContractor(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")}) return } @@ -122,15 +123,15 @@ func (h *ContractorHandler) DeleteContractor(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrContractorNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")}) case errors.Is(err, services.ErrContractorAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Contractor deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.contractor_deleted")}) } // ToggleFavorite handles POST /api/contractors/:id/toggle-favorite/ @@ -138,7 +139,7 @@ func (h *ContractorHandler) ToggleFavorite(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")}) return } @@ -146,9 +147,9 @@ func (h *ContractorHandler) ToggleFavorite(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrContractorNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")}) case errors.Is(err, services.ErrContractorAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -162,7 +163,7 @@ func (h *ContractorHandler) GetContractorTasks(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")}) return } @@ -170,9 +171,9 @@ func (h *ContractorHandler) GetContractorTasks(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrContractorNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")}) case errors.Is(err, services.ErrContractorAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -186,14 +187,14 @@ func (h *ContractorHandler) ListContractorsByResidence(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } response, err := h.contractorService.ListContractorsByResidence(uint(residenceID), user.ID) if err != nil { if errors.Is(err, services.ErrResidenceAccessDenied) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/handlers/document_handler.go b/internal/handlers/document_handler.go index b94c9c5..0b82f4d 100644 --- a/internal/handlers/document_handler.go +++ b/internal/handlers/document_handler.go @@ -12,6 +12,7 @@ import ( "github.com/shopspring/decimal" "github.com/treytartt/casera-api/internal/dto/requests" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -47,7 +48,7 @@ func (h *DocumentHandler) GetDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")}) return } @@ -55,9 +56,9 @@ func (h *DocumentHandler) GetDocument(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrDocumentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")}) case errors.Is(err, services.ErrDocumentAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -89,19 +90,19 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) { if strings.HasPrefix(contentType, "multipart/form-data") { // Parse multipart form if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")}) return } // Parse residence_id (required) residenceIDStr := c.PostForm("residence_id") if residenceIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "residence_id is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_id_required")}) return } residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid residence_id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } req.ResidenceID = uint(residenceID) @@ -109,7 +110,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) { // Parse title (required) req.Title = c.PostForm("title") if req.Title == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.title_required")}) return } @@ -170,7 +171,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) { if uploadedFile != nil && h.storageService != nil { result, err := h.storageService.Upload(uploadedFile, "documents") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload file: " + err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_file")}) return } req.FileURL = result.URL @@ -190,7 +191,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) { response, err := h.documentService.CreateDocument(&req, user.ID) if err != nil { if errors.Is(err, services.ErrResidenceAccessDenied) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -204,7 +205,7 @@ func (h *DocumentHandler) UpdateDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")}) return } @@ -218,9 +219,9 @@ func (h *DocumentHandler) UpdateDocument(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrDocumentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")}) case errors.Is(err, services.ErrDocumentAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -234,7 +235,7 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")}) return } @@ -242,15 +243,15 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrDocumentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")}) case errors.Is(err, services.ErrDocumentAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deleted")}) } // ActivateDocument handles POST /api/documents/:id/activate/ @@ -258,7 +259,7 @@ func (h *DocumentHandler) ActivateDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")}) return } @@ -266,15 +267,15 @@ func (h *DocumentHandler) ActivateDocument(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrDocumentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")}) case errors.Is(err, services.ErrDocumentAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Document activated", "document": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_activated"), "document": response}) } // DeactivateDocument handles POST /api/documents/:id/deactivate/ @@ -282,7 +283,7 @@ func (h *DocumentHandler) DeactivateDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")}) return } @@ -290,13 +291,13 @@ func (h *DocumentHandler) DeactivateDocument(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrDocumentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")}) case errors.Is(err, services.ErrDocumentAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Document deactivated", "document": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deactivated"), "document": response}) } diff --git a/internal/handlers/notification_handler.go b/internal/handlers/notification_handler.go index 4fe0225..369ff86 100644 --- a/internal/handlers/notification_handler.go +++ b/internal/handlers/notification_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -70,21 +71,21 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) { notificationID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_notification_id")}) return } err = h.notificationService.MarkAsRead(uint(notificationID), user.ID) if err != nil { if errors.Is(err, services.ErrNotificationNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.notification_not_found")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.notification_marked_read")}) } // MarkAllAsRead handles POST /api/notifications/mark-all-read/ @@ -97,7 +98,7 @@ func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.all_notifications_marked_read")}) } // GetPreferences handles GET /api/notifications/preferences/ @@ -145,7 +146,7 @@ func (h *NotificationHandler) RegisterDevice(c *gin.Context) { device, err := h.notificationService.RegisterDevice(user.ID, &req) if err != nil { if errors.Is(err, services.ErrInvalidPlatform) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -174,7 +175,7 @@ func (h *NotificationHandler) DeleteDevice(c *gin.Context) { deviceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid device ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_device_id")}) return } @@ -186,12 +187,12 @@ func (h *NotificationHandler) DeleteDevice(c *gin.Context) { err = h.notificationService.DeleteDevice(uint(deviceID), platform, user.ID) if err != nil { if errors.Is(err, services.ErrInvalidPlatform) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "Device removed"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.device_removed")}) } diff --git a/internal/handlers/residence_handler.go b/internal/handlers/residence_handler.go index 0ecf651..81f5a67 100644 --- a/internal/handlers/residence_handler.go +++ b/internal/handlers/residence_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/treytartt/casera-api/internal/dto/requests" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -61,7 +62,7 @@ func (h *ResidenceHandler) GetResidence(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -69,9 +70,9 @@ func (h *ResidenceHandler) GetResidence(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrResidenceAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -94,7 +95,7 @@ func (h *ResidenceHandler) CreateResidence(c *gin.Context) { response, err := h.residenceService.CreateResidence(&req, user.ID) if err != nil { if errors.Is(err, services.ErrPropertiesLimitReached) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.properties_limit_reached")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -110,7 +111,7 @@ func (h *ResidenceHandler) UpdateResidence(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -124,9 +125,9 @@ func (h *ResidenceHandler) UpdateResidence(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrNotResidenceOwner): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -142,7 +143,7 @@ func (h *ResidenceHandler) DeleteResidence(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -150,16 +151,16 @@ func (h *ResidenceHandler) DeleteResidence(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrNotResidenceOwner): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Residence deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.residence_deleted")}) } // GenerateShareCode handles POST /api/residences/:id/generate-share-code/ @@ -168,7 +169,7 @@ func (h *ResidenceHandler) GenerateShareCode(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -180,9 +181,9 @@ func (h *ResidenceHandler) GenerateShareCode(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrNotResidenceOwner): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -206,11 +207,11 @@ func (h *ResidenceHandler) JoinWithCode(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrShareCodeInvalid): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_invalid")}) case errors.Is(err, services.ErrShareCodeExpired): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_expired")}) case errors.Is(err, services.ErrUserAlreadyMember): - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + c.JSON(http.StatusConflict, gin.H{"error": i18n.LocalizedMessage(c, "error.user_already_member")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -226,7 +227,7 @@ func (h *ResidenceHandler) GetResidenceUsers(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -234,9 +235,9 @@ func (h *ResidenceHandler) GetResidenceUsers(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrResidenceAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -252,13 +253,13 @@ func (h *ResidenceHandler) RemoveResidenceUser(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } userIDToRemove, err := strconv.ParseUint(c.Param("user_id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")}) return } @@ -266,18 +267,18 @@ func (h *ResidenceHandler) RemoveResidenceUser(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrNotResidenceOwner): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")}) case errors.Is(err, services.ErrCannotRemoveOwner): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.cannot_remove_owner")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "User removed from residence"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.user_removed")}) } // GetResidenceTypes handles GET /api/residences/types/ @@ -298,7 +299,7 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -313,9 +314,9 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")}) case errors.Is(err, services.ErrResidenceAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -360,11 +361,11 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { } // Build response message - message := "Tasks report generated successfully" + message := i18n.LocalizedMessage(c, "message.tasks_report_generated") if pdfGenerated && emailSent { - message = "Tasks report generated and sent to " + recipientEmail + message = i18n.LocalizedMessageWithData(c, "message.tasks_report_sent", map[string]interface{}{"Email": recipientEmail}) } else if pdfGenerated && !emailSent { - message = "Tasks report generated but email could not be sent" + message = i18n.LocalizedMessage(c, "message.tasks_report_email_failed") } c.JSON(http.StatusOK, gin.H{ diff --git a/internal/handlers/static_data_handler.go b/internal/handlers/static_data_handler.go index a76658d..b3e4907 100644 --- a/internal/handlers/static_data_handler.go +++ b/internal/handlers/static_data_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/services" ) @@ -34,37 +35,37 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) { // Get all lookup data residenceTypes, err := h.residenceService.GetResidenceTypes() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence types"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_residence_types")}) return } taskCategories, err := h.taskService.GetCategories() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task categories"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_categories")}) return } taskPriorities, err := h.taskService.GetPriorities() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task priorities"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_priorities")}) return } taskFrequencies, err := h.taskService.GetFrequencies() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task frequencies"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_frequencies")}) return } taskStatuses, err := h.taskService.GetStatuses() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task statuses"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_statuses")}) return } contractorSpecialties, err := h.contractorService.GetSpecialties() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor specialties"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_contractor_specialties")}) return } @@ -83,7 +84,7 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) { // Kept for API compatibility with mobile clients func (h *StaticDataHandler) RefreshStaticData(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "message": "Static data refreshed", + "message": i18n.LocalizedMessage(c, "message.static_data_refreshed"), "status": "success", }) } diff --git a/internal/handlers/subscription_handler.go b/internal/handlers/subscription_handler.go index cb87ce7..f1ab514 100644 --- a/internal/handlers/subscription_handler.go +++ b/internal/handlers/subscription_handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -54,7 +55,7 @@ func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) { trigger, err := h.subscriptionService.GetUpgradeTrigger(key) if err != nil { if errors.Is(err, services.ErrUpgradeTriggerNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.upgrade_trigger_not_found")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -115,13 +116,13 @@ func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) { switch req.Platform { case "ios": if req.ReceiptData == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "receipt_data is required for iOS"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.receipt_data_required")}) return } subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData) case "android": if req.PurchaseToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "purchase_token is required for Android"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.purchase_token_required")}) return } subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken) @@ -133,7 +134,7 @@ func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "message": "Subscription upgraded successfully", + "message": i18n.LocalizedMessage(c, "message.subscription_upgraded"), "subscription": subscription, }) } @@ -149,7 +150,7 @@ func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "message": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.", + "message": i18n.LocalizedMessage(c, "message.subscription_cancelled"), "subscription": subscription, }) } @@ -181,7 +182,7 @@ func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "message": "Subscription restored successfully", + "message": i18n.LocalizedMessage(c, "message.subscription_restored"), "subscription": subscription, }) } diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index 9145770..0edf968 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -12,6 +12,7 @@ import ( "github.com/shopspring/decimal" "github.com/treytartt/casera-api/internal/dto/requests" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -47,7 +48,7 @@ func (h *TaskHandler) GetTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -55,9 +56,9 @@ func (h *TaskHandler) GetTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -71,7 +72,7 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) return } @@ -86,7 +87,7 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrResidenceAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -107,7 +108,7 @@ func (h *TaskHandler) CreateTask(c *gin.Context) { response, err := h.taskService.CreateTask(&req, user.ID) if err != nil { if errors.Is(err, services.ErrResidenceAccessDenied) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -121,7 +122,7 @@ func (h *TaskHandler) UpdateTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -135,9 +136,9 @@ func (h *TaskHandler) UpdateTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -151,7 +152,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -159,15 +160,15 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_deleted")}) } // MarkInProgress handles POST /api/tasks/:id/mark-in-progress/ @@ -175,7 +176,7 @@ func (h *TaskHandler) MarkInProgress(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -183,15 +184,15 @@ func (h *TaskHandler) MarkInProgress(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task marked as in progress", "task": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_in_progress"), "task": response}) } // CancelTask handles POST /api/tasks/:id/cancel/ @@ -199,7 +200,7 @@ func (h *TaskHandler) CancelTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -207,17 +208,17 @@ func (h *TaskHandler) CancelTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) case errors.Is(err, services.ErrTaskAlreadyCancelled): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_cancelled")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task cancelled", "task": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_cancelled"), "task": response}) } // UncancelTask handles POST /api/tasks/:id/uncancel/ @@ -225,7 +226,7 @@ func (h *TaskHandler) UncancelTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -233,15 +234,15 @@ func (h *TaskHandler) UncancelTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task uncancelled", "task": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_uncancelled"), "task": response}) } // ArchiveTask handles POST /api/tasks/:id/archive/ @@ -249,7 +250,7 @@ func (h *TaskHandler) ArchiveTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -257,17 +258,17 @@ func (h *TaskHandler) ArchiveTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) case errors.Is(err, services.ErrTaskAlreadyArchived): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_archived")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task archived", "task": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_archived"), "task": response}) } // UnarchiveTask handles POST /api/tasks/:id/unarchive/ @@ -275,7 +276,7 @@ func (h *TaskHandler) UnarchiveTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -283,15 +284,15 @@ func (h *TaskHandler) UnarchiveTask(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Task unarchived", "task": response}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_unarchived"), "task": response}) } // === Task Completions === @@ -301,7 +302,7 @@ func (h *TaskHandler) GetTaskCompletions(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } @@ -309,9 +310,9 @@ func (h *TaskHandler) GetTaskCompletions(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -336,7 +337,7 @@ func (h *TaskHandler) GetCompletion(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")}) return } @@ -344,9 +345,9 @@ func (h *TaskHandler) GetCompletion(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrCompletionNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -367,19 +368,19 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) { if strings.HasPrefix(contentType, "multipart/form-data") { // Parse multipart form if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")}) return } // Parse task_id (required) taskIDStr := c.PostForm("task_id") if taskIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "task_id is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_id_required")}) return } taskID, err := strconv.ParseUint(taskIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task_id"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id_value")}) return } req.TaskID = uint(taskID) @@ -416,7 +417,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) { if h.storageService != nil { result, err := h.storageService.Upload(file, "completions") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_image")}) return } req.ImageURLs = append(req.ImageURLs, result.URL) @@ -434,9 +435,9 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } @@ -450,7 +451,7 @@ func (h *TaskHandler) DeleteCompletion(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")}) return } @@ -458,15 +459,15 @@ func (h *TaskHandler) DeleteCompletion(c *gin.Context) { if err != nil { switch { case errors.Is(err, services.ErrCompletionNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")}) case errors.Is(err, services.ErrTaskAccessDenied): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "Completion deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.completion_deleted")}) } // === Lookups === diff --git a/internal/handlers/upload_handler.go b/internal/handlers/upload_handler.go index c8bfe9a..ddea74a 100644 --- a/internal/handlers/upload_handler.go +++ b/internal/handlers/upload_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/services" ) @@ -23,7 +24,7 @@ func NewUploadHandler(storageService *services.StorageService) *UploadHandler { func (h *UploadHandler) UploadImage(c *gin.Context) { file, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")}) return } @@ -44,7 +45,7 @@ func (h *UploadHandler) UploadImage(c *gin.Context) { func (h *UploadHandler) UploadDocument(c *gin.Context) { file, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")}) return } @@ -62,7 +63,7 @@ func (h *UploadHandler) UploadDocument(c *gin.Context) { func (h *UploadHandler) UploadCompletion(c *gin.Context) { file, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")}) return } @@ -92,5 +93,5 @@ func (h *UploadHandler) DeleteFile(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"}) + c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.file_deleted")}) } diff --git a/internal/handlers/user_handler.go b/internal/handlers/user_handler.go index c63c28f..bcdebe9 100644 --- a/internal/handlers/user_handler.go +++ b/internal/handlers/user_handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/services" @@ -46,7 +47,7 @@ func (h *UserHandler) GetUser(c *gin.Context) { userID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")}) return } @@ -54,7 +55,7 @@ func (h *UserHandler) GetUser(c *gin.Context) { targetUser, err := h.userService.GetUserIfSharedResidence(uint(userID), user.ID) if err != nil { if err == services.ErrUserNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.user_not_found")}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..2306841 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,86 @@ +package i18n + +import ( + "embed" + "encoding/json" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/rs/zerolog/log" + "golang.org/x/text/language" +) + +//go:embed translations/*.json +var translationFS embed.FS + +// Bundle is the global i18n bundle +var Bundle *i18n.Bundle + +// SupportedLanguages lists all supported language codes +var SupportedLanguages = []string{"en", "es", "fr", "de", "pt"} + +// DefaultLanguage is the fallback language +const DefaultLanguage = "en" + +// Init initializes the i18n bundle with embedded translation files +func Init() error { + Bundle = i18n.NewBundle(language.English) + Bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + // Load all translation files + for _, lang := range SupportedLanguages { + path := "translations/" + lang + ".json" + data, err := translationFS.ReadFile(path) + if err != nil { + log.Warn().Str("language", lang).Err(err).Msg("Failed to load translation file") + continue + } + Bundle.MustParseMessageFileBytes(data, path) + log.Info().Str("language", lang).Msg("Loaded translation file") + } + + return nil +} + +// NewLocalizer creates a new localizer for the given language tags +func NewLocalizer(langs ...string) *i18n.Localizer { + return i18n.NewLocalizer(Bundle, langs...) +} + +// T translates a message ID with optional template data +func T(localizer *i18n.Localizer, messageID string, templateData map[string]interface{}) string { + if localizer == nil { + localizer = NewLocalizer(DefaultLanguage) + } + + msg, err := localizer.Localize(&i18n.LocalizeConfig{ + MessageID: messageID, + TemplateData: templateData, + }) + if err != nil { + // Fallback to message ID if translation not found + log.Debug().Str("message_id", messageID).Err(err).Msg("Translation not found") + return messageID + } + return msg +} + +// TSimple translates a message ID without template data +func TSimple(localizer *i18n.Localizer, messageID string) string { + return T(localizer, messageID, nil) +} + +// MustT translates a message ID or panics +func MustT(localizer *i18n.Localizer, messageID string, templateData map[string]interface{}) string { + if localizer == nil { + localizer = NewLocalizer(DefaultLanguage) + } + + msg, err := localizer.Localize(&i18n.LocalizeConfig{ + MessageID: messageID, + TemplateData: templateData, + }) + if err != nil { + panic(err) + } + return msg +} diff --git a/internal/i18n/middleware.go b/internal/i18n/middleware.go new file mode 100644 index 0000000..a3616db --- /dev/null +++ b/internal/i18n/middleware.go @@ -0,0 +1,122 @@ +package i18n + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +const ( + // LocalizerKey is the key used to store the localizer in Gin context + LocalizerKey = "i18n_localizer" + // LocaleKey is the key used to store the detected locale in Gin context + LocaleKey = "i18n_locale" +) + +// Middleware returns a Gin middleware that detects the user's preferred language +// from the Accept-Language header and stores a localizer in the context +func Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Get Accept-Language header + acceptLang := c.GetHeader("Accept-Language") + + // Parse the preferred languages + langs := parseAcceptLanguage(acceptLang) + + // Create localizer with the preferred languages + localizer := NewLocalizer(langs...) + + // Determine the best matched locale for storage + locale := matchLocale(langs) + + // Store in context + c.Set(LocalizerKey, localizer) + c.Set(LocaleKey, locale) + + c.Next() + } +} + +// parseAcceptLanguage parses the Accept-Language header and returns a slice of language tags +func parseAcceptLanguage(header string) []string { + if header == "" { + return []string{DefaultLanguage} + } + + // Parse using golang.org/x/text/language + tags, _, err := language.ParseAcceptLanguage(header) + if err != nil || len(tags) == 0 { + return []string{DefaultLanguage} + } + + // Convert to string slice and normalize + langs := make([]string, 0, len(tags)) + for _, tag := range tags { + base, _ := tag.Base() + lang := strings.ToLower(base.String()) + + // Only add supported languages + for _, supported := range SupportedLanguages { + if lang == supported { + langs = append(langs, lang) + break + } + } + } + + // If no supported languages found, use default + if len(langs) == 0 { + return []string{DefaultLanguage} + } + + return langs +} + +// matchLocale returns the best matching locale from the provided languages +func matchLocale(langs []string) string { + for _, lang := range langs { + for _, supported := range SupportedLanguages { + if lang == supported { + return supported + } + } + } + return DefaultLanguage +} + +// GetLocalizer retrieves the localizer from the Gin context +func GetLocalizer(c *gin.Context) *i18n.Localizer { + if localizer, exists := c.Get(LocalizerKey); exists { + if l, ok := localizer.(*i18n.Localizer); ok { + return l + } + } + return NewLocalizer(DefaultLanguage) +} + +// GetLocale retrieves the detected locale from the Gin context +func GetLocale(c *gin.Context) string { + if locale, exists := c.Get(LocaleKey); exists { + if l, ok := locale.(string); ok { + return l + } + } + return DefaultLanguage +} + +// LocalizedError returns a localized error message +func LocalizedError(c *gin.Context, messageID string, templateData map[string]interface{}) string { + return T(GetLocalizer(c), messageID, templateData) +} + +// LocalizedMessage returns a localized message +func LocalizedMessage(c *gin.Context, messageID string) string { + return TSimple(GetLocalizer(c), messageID) +} + +// LocalizedMessageWithData returns a localized message with template data +func LocalizedMessageWithData(c *gin.Context, messageID string, templateData map[string]interface{}) string { + return T(GetLocalizer(c), messageID, templateData) +} diff --git a/internal/i18n/translations/de.json b/internal/i18n/translations/de.json new file mode 100644 index 0000000..14ac238 --- /dev/null +++ b/internal/i18n/translations/de.json @@ -0,0 +1,187 @@ +{ + "error.invalid_request_body": "Ungultiger Anforderungstext", + "error.invalid_credentials": "Ungultige Anmeldedaten", + "error.account_inactive": "Das Konto ist inaktiv", + "error.username_taken": "Benutzername bereits vergeben", + "error.email_taken": "E-Mail bereits registriert", + "error.email_already_taken": "E-Mail bereits vergeben", + "error.registration_failed": "Registrierung fehlgeschlagen", + "error.not_authenticated": "Nicht authentifiziert", + "error.failed_to_get_user": "Benutzer konnte nicht abgerufen werden", + "error.failed_to_update_profile": "Profil konnte nicht aktualisiert werden", + "error.invalid_verification_code": "Ungultiger Verifizierungscode", + "error.verification_code_expired": "Der Verifizierungscode ist abgelaufen", + "error.email_already_verified": "E-Mail bereits verifiziert", + "error.verification_failed": "Verifizierung fehlgeschlagen", + "error.failed_to_resend_verification": "Verifizierung konnte nicht erneut gesendet werden", + "error.rate_limit_exceeded": "Zu viele Anfragen zur Passwortzurucksetzung. Bitte versuchen Sie es spater erneut.", + "error.too_many_attempts": "Zu viele Versuche. Bitte fordern Sie einen neuen Code an.", + "error.invalid_reset_token": "Ungultiger oder abgelaufener Zurucksetzungs-Token", + "error.password_reset_failed": "Passwortzurucksetzung fehlgeschlagen", + "error.apple_signin_not_configured": "Apple-Anmeldung ist nicht konfiguriert", + "error.apple_signin_failed": "Apple-Anmeldung fehlgeschlagen", + "error.invalid_apple_token": "Ungultiger Apple-Identitats-Token", + + "error.invalid_task_id": "Ungultige Aufgaben-ID", + "error.invalid_residence_id": "Ungultige Immobilien-ID", + "error.invalid_contractor_id": "Ungultige Dienstleister-ID", + "error.invalid_document_id": "Ungultige Dokument-ID", + "error.invalid_completion_id": "Ungultige Abschluss-ID", + "error.invalid_user_id": "Ungultige Benutzer-ID", + "error.invalid_notification_id": "Ungultige Benachrichtigungs-ID", + "error.invalid_device_id": "Ungultige Gerate-ID", + + "error.task_not_found": "Aufgabe nicht gefunden", + "error.residence_not_found": "Immobilie nicht gefunden", + "error.contractor_not_found": "Dienstleister nicht gefunden", + "error.document_not_found": "Dokument nicht gefunden", + "error.completion_not_found": "Aufgabenabschluss nicht gefunden", + "error.user_not_found": "Benutzer nicht gefunden", + "error.share_code_invalid": "Ungultiger Freigabecode", + "error.share_code_expired": "Der Freigabecode ist abgelaufen", + + "error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe", + "error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie", + "error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister", + "error.document_access_denied": "Sie haben keinen Zugriff auf dieses Dokument", + "error.not_residence_owner": "Nur der Eigentumer kann diese Aktion durchfuhren", + "error.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden", + "error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie", + "error.properties_limit_reached": "Sie haben die maximale Anzahl an Immobilien fur Ihr Abonnement erreicht", + + "error.task_already_cancelled": "Die Aufgabe ist bereits storniert", + "error.task_already_archived": "Die Aufgabe ist bereits archiviert", + + "error.failed_to_parse_form": "Formular konnte nicht analysiert werden", + "error.task_id_required": "task_id ist erforderlich", + "error.invalid_task_id_value": "Ungultige task_id", + "error.failed_to_upload_image": "Bild konnte nicht hochgeladen werden", + "error.residence_id_required": "residence_id ist erforderlich", + "error.invalid_residence_id_value": "Ungultige residence_id", + "error.title_required": "Titel ist erforderlich", + "error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden", + + "message.logged_out": "Erfolgreich abgemeldet", + "message.email_verified": "E-Mail erfolgreich verifiziert", + "message.verification_email_sent": "Verifizierungs-E-Mail gesendet", + "message.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.", + "message.reset_code_verified": "Code erfolgreich verifiziert", + "message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.", + + "message.task_deleted": "Aufgabe erfolgreich geloscht", + "message.task_in_progress": "Aufgabe als in Bearbeitung markiert", + "message.task_cancelled": "Aufgabe storniert", + "message.task_uncancelled": "Aufgabe reaktiviert", + "message.task_archived": "Aufgabe archiviert", + "message.task_unarchived": "Aufgabe dearchiviert", + "message.completion_deleted": "Abschluss erfolgreich geloscht", + + "message.residence_deleted": "Immobilie erfolgreich geloscht", + "message.user_removed": "Benutzer von der Immobilie entfernt", + "message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt", + "message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet", + "message.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden", + + "message.contractor_deleted": "Dienstleister erfolgreich geloscht", + + "message.document_deleted": "Dokument erfolgreich geloscht", + "message.document_activated": "Dokument aktiviert", + "message.document_deactivated": "Dokument deaktiviert", + + "message.notification_marked_read": "Benachrichtigung als gelesen markiert", + "message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert", + "message.device_removed": "Gerät entfernt", + + "message.subscription_upgraded": "Abonnement erfolgreich aktualisiert", + "message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.", + "message.subscription_restored": "Abonnement erfolgreich wiederhergestellt", + + "message.file_deleted": "Datei erfolgreich gelöscht", + "message.static_data_refreshed": "Statische Daten aktualisiert", + + "error.notification_not_found": "Benachrichtigung nicht gefunden", + "error.invalid_platform": "Ungültige Plattform", + + "error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden", + "error.receipt_data_required": "receipt_data ist für iOS erforderlich", + "error.purchase_token_required": "purchase_token ist für Android erforderlich", + + "error.no_file_provided": "Keine Datei bereitgestellt", + + "error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen", + "error.failed_to_fetch_task_categories": "Fehler beim Abrufen der Aufgabenkategorien", + "error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten", + "error.failed_to_fetch_task_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen", + "error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus", + "error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten", + + "push.task_due_soon.title": "Aufgabe Bald Fallig", + "push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}", + "push.task_overdue.title": "Uberfällige Aufgabe", + "push.task_overdue.body": "{{.TaskTitle}} ist uberfallig", + "push.task_completed.title": "Aufgabe Abgeschlossen", + "push.task_completed.body": "{{.UserName}} hat {{.TaskTitle}} abgeschlossen", + "push.task_assigned.title": "Neue Aufgabe Zugewiesen", + "push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen", + "push.residence_shared.title": "Immobilie Geteilt", + "push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt", + + "email.welcome.subject": "Willkommen bei Casera!", + "email.verification.subject": "Bestatigen Sie Ihre E-Mail", + "email.password_reset.subject": "Passwort-Zurucksetzungscode", + "email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}", + + "lookup.residence_type.house": "Haus", + "lookup.residence_type.apartment": "Wohnung", + "lookup.residence_type.condo": "Eigentumswohnung", + "lookup.residence_type.townhouse": "Reihenhaus", + "lookup.residence_type.mobile_home": "Mobilheim", + "lookup.residence_type.other": "Sonstiges", + + "lookup.task_category.plumbing": "Sanitär", + "lookup.task_category.electrical": "Elektrik", + "lookup.task_category.hvac": "Heizung/Klimaanlage", + "lookup.task_category.appliances": "Gerate", + "lookup.task_category.exterior": "Aussenbereich", + "lookup.task_category.interior": "Innenbereich", + "lookup.task_category.landscaping": "Gartenpflege", + "lookup.task_category.safety": "Sicherheit", + "lookup.task_category.cleaning": "Reinigung", + "lookup.task_category.pest_control": "Schadlingsbekampfung", + "lookup.task_category.seasonal": "Saisonal", + "lookup.task_category.other": "Sonstiges", + + "lookup.task_priority.low": "Niedrig", + "lookup.task_priority.medium": "Mittel", + "lookup.task_priority.high": "Hoch", + "lookup.task_priority.urgent": "Dringend", + + "lookup.task_status.pending": "Ausstehend", + "lookup.task_status.in_progress": "In Bearbeitung", + "lookup.task_status.completed": "Abgeschlossen", + "lookup.task_status.cancelled": "Storniert", + "lookup.task_status.archived": "Archiviert", + + "lookup.task_frequency.once": "Einmalig", + "lookup.task_frequency.daily": "Taglich", + "lookup.task_frequency.weekly": "Wochentlich", + "lookup.task_frequency.biweekly": "Alle 2 Wochen", + "lookup.task_frequency.monthly": "Monatlich", + "lookup.task_frequency.quarterly": "Vierteljahrlich", + "lookup.task_frequency.semiannually": "Halbjahrlich", + "lookup.task_frequency.annually": "Jahrlich", + + "lookup.contractor_specialty.plumber": "Klempner", + "lookup.contractor_specialty.electrician": "Elektriker", + "lookup.contractor_specialty.hvac_technician": "HLK-Techniker", + "lookup.contractor_specialty.handyman": "Handwerker", + "lookup.contractor_specialty.landscaper": "Landschaftsgartner", + "lookup.contractor_specialty.roofer": "Dachdecker", + "lookup.contractor_specialty.painter": "Maler", + "lookup.contractor_specialty.carpenter": "Schreiner", + "lookup.contractor_specialty.pest_control": "Schadlingsbekampfung", + "lookup.contractor_specialty.cleaning": "Reinigung", + "lookup.contractor_specialty.pool_service": "Pool-Service", + "lookup.contractor_specialty.general_contractor": "Generalunternehmer", + "lookup.contractor_specialty.other": "Sonstiges" +} diff --git a/internal/i18n/translations/en.json b/internal/i18n/translations/en.json new file mode 100644 index 0000000..56f990a --- /dev/null +++ b/internal/i18n/translations/en.json @@ -0,0 +1,187 @@ +{ + "error.invalid_request_body": "Invalid request body", + "error.invalid_credentials": "Invalid credentials", + "error.account_inactive": "Account is inactive", + "error.username_taken": "Username already taken", + "error.email_taken": "Email already registered", + "error.email_already_taken": "Email already taken", + "error.registration_failed": "Registration failed", + "error.not_authenticated": "Not authenticated", + "error.failed_to_get_user": "Failed to get user", + "error.failed_to_update_profile": "Failed to update profile", + "error.invalid_verification_code": "Invalid verification code", + "error.verification_code_expired": "Verification code has expired", + "error.email_already_verified": "Email already verified", + "error.verification_failed": "Verification failed", + "error.failed_to_resend_verification": "Failed to resend verification", + "error.rate_limit_exceeded": "Too many password reset requests. Please try again later.", + "error.too_many_attempts": "Too many attempts. Please request a new code.", + "error.invalid_reset_token": "Invalid or expired reset token", + "error.password_reset_failed": "Password reset failed", + "error.apple_signin_not_configured": "Apple Sign In is not configured", + "error.apple_signin_failed": "Apple Sign In failed", + "error.invalid_apple_token": "Invalid Apple identity token", + + "error.invalid_task_id": "Invalid task ID", + "error.invalid_residence_id": "Invalid residence ID", + "error.invalid_contractor_id": "Invalid contractor ID", + "error.invalid_document_id": "Invalid document ID", + "error.invalid_completion_id": "Invalid completion ID", + "error.invalid_user_id": "Invalid user ID", + "error.invalid_notification_id": "Invalid notification ID", + "error.invalid_device_id": "Invalid device ID", + + "error.task_not_found": "Task not found", + "error.residence_not_found": "Residence not found", + "error.contractor_not_found": "Contractor not found", + "error.document_not_found": "Document not found", + "error.completion_not_found": "Task completion not found", + "error.user_not_found": "User not found", + "error.share_code_invalid": "Invalid share code", + "error.share_code_expired": "Share code has expired", + + "error.task_access_denied": "You don't have access to this task", + "error.residence_access_denied": "You don't have access to this property", + "error.contractor_access_denied": "You don't have access to this contractor", + "error.document_access_denied": "You don't have access to this document", + "error.not_residence_owner": "Only the property owner can perform this action", + "error.cannot_remove_owner": "Cannot remove the property owner", + "error.user_already_member": "User is already a member of this property", + "error.properties_limit_reached": "You have reached the maximum number of properties for your subscription", + + "error.task_already_cancelled": "Task is already cancelled", + "error.task_already_archived": "Task is already archived", + + "error.failed_to_parse_form": "Failed to parse multipart form", + "error.task_id_required": "task_id is required", + "error.invalid_task_id_value": "Invalid task_id", + "error.failed_to_upload_image": "Failed to upload image", + "error.residence_id_required": "residence_id is required", + "error.invalid_residence_id_value": "Invalid residence_id", + "error.title_required": "title is required", + "error.failed_to_upload_file": "Failed to upload file", + + "message.logged_out": "Logged out successfully", + "message.email_verified": "Email verified successfully", + "message.verification_email_sent": "Verification email sent", + "message.password_reset_email_sent": "If an account with that email exists, a password reset code has been sent.", + "message.reset_code_verified": "Code verified successfully", + "message.password_reset_success": "Password reset successfully. Please log in with your new password.", + + "message.task_deleted": "Task deleted successfully", + "message.task_in_progress": "Task marked as in progress", + "message.task_cancelled": "Task cancelled", + "message.task_uncancelled": "Task uncancelled", + "message.task_archived": "Task archived", + "message.task_unarchived": "Task unarchived", + "message.completion_deleted": "Completion deleted successfully", + + "message.residence_deleted": "Residence deleted successfully", + "message.user_removed": "User removed from residence", + "message.tasks_report_generated": "Tasks report generated successfully", + "message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}", + "message.tasks_report_email_failed": "Tasks report generated but email could not be sent", + + "message.contractor_deleted": "Contractor deleted successfully", + + "message.document_deleted": "Document deleted successfully", + "message.document_activated": "Document activated", + "message.document_deactivated": "Document deactivated", + + "message.notification_marked_read": "Notification marked as read", + "message.all_notifications_marked_read": "All notifications marked as read", + "message.device_removed": "Device removed", + + "message.subscription_upgraded": "Subscription upgraded successfully", + "message.subscription_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.", + "message.subscription_restored": "Subscription restored successfully", + + "message.file_deleted": "File deleted successfully", + "message.static_data_refreshed": "Static data refreshed", + + "error.notification_not_found": "Notification not found", + "error.invalid_platform": "Invalid platform", + + "error.upgrade_trigger_not_found": "Upgrade trigger not found", + "error.receipt_data_required": "receipt_data is required for iOS", + "error.purchase_token_required": "purchase_token is required for Android", + + "error.no_file_provided": "No file provided", + + "error.failed_to_fetch_residence_types": "Failed to fetch residence types", + "error.failed_to_fetch_task_categories": "Failed to fetch task categories", + "error.failed_to_fetch_task_priorities": "Failed to fetch task priorities", + "error.failed_to_fetch_task_frequencies": "Failed to fetch task frequencies", + "error.failed_to_fetch_task_statuses": "Failed to fetch task statuses", + "error.failed_to_fetch_contractor_specialties": "Failed to fetch contractor specialties", + + "push.task_due_soon.title": "Task Due Soon", + "push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}", + "push.task_overdue.title": "Overdue Task", + "push.task_overdue.body": "{{.TaskTitle}} is overdue", + "push.task_completed.title": "Task Completed", + "push.task_completed.body": "{{.UserName}} completed {{.TaskTitle}}", + "push.task_assigned.title": "New Task Assigned", + "push.task_assigned.body": "You have been assigned to {{.TaskTitle}}", + "push.residence_shared.title": "Property Shared", + "push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you", + + "email.welcome.subject": "Welcome to Casera!", + "email.verification.subject": "Verify Your Email", + "email.password_reset.subject": "Password Reset Code", + "email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}", + + "lookup.residence_type.house": "House", + "lookup.residence_type.apartment": "Apartment", + "lookup.residence_type.condo": "Condo", + "lookup.residence_type.townhouse": "Townhouse", + "lookup.residence_type.mobile_home": "Mobile Home", + "lookup.residence_type.other": "Other", + + "lookup.task_category.plumbing": "Plumbing", + "lookup.task_category.electrical": "Electrical", + "lookup.task_category.hvac": "HVAC", + "lookup.task_category.appliances": "Appliances", + "lookup.task_category.exterior": "Exterior", + "lookup.task_category.interior": "Interior", + "lookup.task_category.landscaping": "Landscaping", + "lookup.task_category.safety": "Safety", + "lookup.task_category.cleaning": "Cleaning", + "lookup.task_category.pest_control": "Pest Control", + "lookup.task_category.seasonal": "Seasonal", + "lookup.task_category.other": "Other", + + "lookup.task_priority.low": "Low", + "lookup.task_priority.medium": "Medium", + "lookup.task_priority.high": "High", + "lookup.task_priority.urgent": "Urgent", + + "lookup.task_status.pending": "Pending", + "lookup.task_status.in_progress": "In Progress", + "lookup.task_status.completed": "Completed", + "lookup.task_status.cancelled": "Cancelled", + "lookup.task_status.archived": "Archived", + + "lookup.task_frequency.once": "Once", + "lookup.task_frequency.daily": "Daily", + "lookup.task_frequency.weekly": "Weekly", + "lookup.task_frequency.biweekly": "Every 2 Weeks", + "lookup.task_frequency.monthly": "Monthly", + "lookup.task_frequency.quarterly": "Quarterly", + "lookup.task_frequency.semiannually": "Every 6 Months", + "lookup.task_frequency.annually": "Annually", + + "lookup.contractor_specialty.plumber": "Plumber", + "lookup.contractor_specialty.electrician": "Electrician", + "lookup.contractor_specialty.hvac_technician": "HVAC Technician", + "lookup.contractor_specialty.handyman": "Handyman", + "lookup.contractor_specialty.landscaper": "Landscaper", + "lookup.contractor_specialty.roofer": "Roofer", + "lookup.contractor_specialty.painter": "Painter", + "lookup.contractor_specialty.carpenter": "Carpenter", + "lookup.contractor_specialty.pest_control": "Pest Control", + "lookup.contractor_specialty.cleaning": "Cleaning", + "lookup.contractor_specialty.pool_service": "Pool Service", + "lookup.contractor_specialty.general_contractor": "General Contractor", + "lookup.contractor_specialty.other": "Other" +} diff --git a/internal/i18n/translations/es.json b/internal/i18n/translations/es.json new file mode 100644 index 0000000..a1bb44e --- /dev/null +++ b/internal/i18n/translations/es.json @@ -0,0 +1,187 @@ +{ + "error.invalid_request_body": "Cuerpo de solicitud no valido", + "error.invalid_credentials": "Credenciales no validas", + "error.account_inactive": "La cuenta esta inactiva", + "error.username_taken": "El nombre de usuario ya esta en uso", + "error.email_taken": "El correo electronico ya esta registrado", + "error.email_already_taken": "El correo electronico ya esta en uso", + "error.registration_failed": "Error en el registro", + "error.not_authenticated": "No autenticado", + "error.failed_to_get_user": "Error al obtener el usuario", + "error.failed_to_update_profile": "Error al actualizar el perfil", + "error.invalid_verification_code": "Codigo de verificacion no valido", + "error.verification_code_expired": "El codigo de verificacion ha expirado", + "error.email_already_verified": "El correo electronico ya esta verificado", + "error.verification_failed": "Error en la verificacion", + "error.failed_to_resend_verification": "Error al reenviar la verificacion", + "error.rate_limit_exceeded": "Demasiadas solicitudes de restablecimiento de contrasena. Por favor, intentelo mas tarde.", + "error.too_many_attempts": "Demasiados intentos. Por favor, solicite un nuevo codigo.", + "error.invalid_reset_token": "Token de restablecimiento no valido o expirado", + "error.password_reset_failed": "Error al restablecer la contrasena", + "error.apple_signin_not_configured": "El inicio de sesion con Apple no esta configurado", + "error.apple_signin_failed": "Error en el inicio de sesion con Apple", + "error.invalid_apple_token": "Token de identidad de Apple no valido", + + "error.invalid_task_id": "ID de tarea no valido", + "error.invalid_residence_id": "ID de propiedad no valido", + "error.invalid_contractor_id": "ID de contratista no valido", + "error.invalid_document_id": "ID de documento no valido", + "error.invalid_completion_id": "ID de finalizacion no valido", + "error.invalid_user_id": "ID de usuario no valido", + "error.invalid_notification_id": "ID de notificacion no valido", + "error.invalid_device_id": "ID de dispositivo no valido", + + "error.task_not_found": "Tarea no encontrada", + "error.residence_not_found": "Propiedad no encontrada", + "error.contractor_not_found": "Contratista no encontrado", + "error.document_not_found": "Documento no encontrado", + "error.completion_not_found": "Finalizacion de tarea no encontrada", + "error.user_not_found": "Usuario no encontrado", + "error.share_code_invalid": "Codigo de compartir no valido", + "error.share_code_expired": "El codigo de compartir ha expirado", + + "error.task_access_denied": "No tienes acceso a esta tarea", + "error.residence_access_denied": "No tienes acceso a esta propiedad", + "error.contractor_access_denied": "No tienes acceso a este contratista", + "error.document_access_denied": "No tienes acceso a este documento", + "error.not_residence_owner": "Solo el propietario de la propiedad puede realizar esta accion", + "error.cannot_remove_owner": "No se puede eliminar al propietario de la propiedad", + "error.user_already_member": "El usuario ya es miembro de esta propiedad", + "error.properties_limit_reached": "Has alcanzado el numero maximo de propiedades para tu suscripcion", + + "error.task_already_cancelled": "La tarea ya esta cancelada", + "error.task_already_archived": "La tarea ya esta archivada", + + "error.failed_to_parse_form": "Error al analizar el formulario", + "error.task_id_required": "Se requiere task_id", + "error.invalid_task_id_value": "task_id no valido", + "error.failed_to_upload_image": "Error al subir la imagen", + "error.residence_id_required": "Se requiere residence_id", + "error.invalid_residence_id_value": "residence_id no valido", + "error.title_required": "Se requiere el titulo", + "error.failed_to_upload_file": "Error al subir el archivo", + + "message.logged_out": "Sesion cerrada correctamente", + "message.email_verified": "Correo electronico verificado correctamente", + "message.verification_email_sent": "Correo de verificacion enviado", + "message.password_reset_email_sent": "Si existe una cuenta con ese correo electronico, se ha enviado un codigo de restablecimiento de contrasena.", + "message.reset_code_verified": "Codigo verificado correctamente", + "message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.", + + "message.task_deleted": "Tarea eliminada correctamente", + "message.task_in_progress": "Tarea marcada como en progreso", + "message.task_cancelled": "Tarea cancelada", + "message.task_uncancelled": "Tarea reactivada", + "message.task_archived": "Tarea archivada", + "message.task_unarchived": "Tarea desarchivada", + "message.completion_deleted": "Finalizacion eliminada correctamente", + + "message.residence_deleted": "Propiedad eliminada correctamente", + "message.user_removed": "Usuario eliminado de la propiedad", + "message.tasks_report_generated": "Informe de tareas generado correctamente", + "message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}", + "message.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo", + + "message.contractor_deleted": "Contratista eliminado correctamente", + + "message.document_deleted": "Documento eliminado correctamente", + "message.document_activated": "Documento activado", + "message.document_deactivated": "Documento desactivado", + + "message.notification_marked_read": "Notificación marcada como leída", + "message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas", + "message.device_removed": "Dispositivo eliminado", + + "message.subscription_upgraded": "Suscripción actualizada correctamente", + "message.subscription_cancelled": "Suscripción cancelada. Mantendrás los beneficios Pro hasta el final de tu período de facturación.", + "message.subscription_restored": "Suscripción restaurada correctamente", + + "message.file_deleted": "Archivo eliminado correctamente", + "message.static_data_refreshed": "Datos estáticos actualizados", + + "error.notification_not_found": "Notificación no encontrada", + "error.invalid_platform": "Plataforma no válida", + + "error.upgrade_trigger_not_found": "Trigger de actualización no encontrado", + "error.receipt_data_required": "Se requiere receipt_data para iOS", + "error.purchase_token_required": "Se requiere purchase_token para Android", + + "error.no_file_provided": "No se proporcionó ningún archivo", + + "error.failed_to_fetch_residence_types": "Error al obtener los tipos de propiedad", + "error.failed_to_fetch_task_categories": "Error al obtener las categorías de tareas", + "error.failed_to_fetch_task_priorities": "Error al obtener las prioridades de tareas", + "error.failed_to_fetch_task_frequencies": "Error al obtener las frecuencias de tareas", + "error.failed_to_fetch_task_statuses": "Error al obtener los estados de tareas", + "error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas", + + "push.task_due_soon.title": "Tarea Proxima a Vencer", + "push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}", + "push.task_overdue.title": "Tarea Vencida", + "push.task_overdue.body": "{{.TaskTitle}} esta vencida", + "push.task_completed.title": "Tarea Completada", + "push.task_completed.body": "{{.UserName}} completo {{.TaskTitle}}", + "push.task_assigned.title": "Nueva Tarea Asignada", + "push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}", + "push.residence_shared.title": "Propiedad Compartida", + "push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo", + + "email.welcome.subject": "Bienvenido a Casera!", + "email.verification.subject": "Verifica Tu Correo Electronico", + "email.password_reset.subject": "Codigo de Restablecimiento de Contrasena", + "email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}", + + "lookup.residence_type.house": "Casa", + "lookup.residence_type.apartment": "Apartamento", + "lookup.residence_type.condo": "Condominio", + "lookup.residence_type.townhouse": "Casa Adosada", + "lookup.residence_type.mobile_home": "Casa Movil", + "lookup.residence_type.other": "Otro", + + "lookup.task_category.plumbing": "Plomeria", + "lookup.task_category.electrical": "Electricidad", + "lookup.task_category.hvac": "Climatizacion", + "lookup.task_category.appliances": "Electrodomesticos", + "lookup.task_category.exterior": "Exterior", + "lookup.task_category.interior": "Interior", + "lookup.task_category.landscaping": "Jardineria", + "lookup.task_category.safety": "Seguridad", + "lookup.task_category.cleaning": "Limpieza", + "lookup.task_category.pest_control": "Control de Plagas", + "lookup.task_category.seasonal": "Estacional", + "lookup.task_category.other": "Otro", + + "lookup.task_priority.low": "Baja", + "lookup.task_priority.medium": "Media", + "lookup.task_priority.high": "Alta", + "lookup.task_priority.urgent": "Urgente", + + "lookup.task_status.pending": "Pendiente", + "lookup.task_status.in_progress": "En Progreso", + "lookup.task_status.completed": "Completada", + "lookup.task_status.cancelled": "Cancelada", + "lookup.task_status.archived": "Archivada", + + "lookup.task_frequency.once": "Una Vez", + "lookup.task_frequency.daily": "Diario", + "lookup.task_frequency.weekly": "Semanal", + "lookup.task_frequency.biweekly": "Cada 2 Semanas", + "lookup.task_frequency.monthly": "Mensual", + "lookup.task_frequency.quarterly": "Trimestral", + "lookup.task_frequency.semiannually": "Cada 6 Meses", + "lookup.task_frequency.annually": "Anual", + + "lookup.contractor_specialty.plumber": "Plomero", + "lookup.contractor_specialty.electrician": "Electricista", + "lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacion", + "lookup.contractor_specialty.handyman": "Manitas", + "lookup.contractor_specialty.landscaper": "Jardinero", + "lookup.contractor_specialty.roofer": "Techador", + "lookup.contractor_specialty.painter": "Pintor", + "lookup.contractor_specialty.carpenter": "Carpintero", + "lookup.contractor_specialty.pest_control": "Control de Plagas", + "lookup.contractor_specialty.cleaning": "Limpieza", + "lookup.contractor_specialty.pool_service": "Servicio de Piscina", + "lookup.contractor_specialty.general_contractor": "Contratista General", + "lookup.contractor_specialty.other": "Otro" +} diff --git a/internal/i18n/translations/fr.json b/internal/i18n/translations/fr.json new file mode 100644 index 0000000..7477a7a --- /dev/null +++ b/internal/i18n/translations/fr.json @@ -0,0 +1,187 @@ +{ + "error.invalid_request_body": "Corps de requete non valide", + "error.invalid_credentials": "Identifiants non valides", + "error.account_inactive": "Le compte est inactif", + "error.username_taken": "Nom d'utilisateur deja pris", + "error.email_taken": "Email deja enregistre", + "error.email_already_taken": "Email deja utilise", + "error.registration_failed": "Echec de l'inscription", + "error.not_authenticated": "Non authentifie", + "error.failed_to_get_user": "Echec de la recuperation de l'utilisateur", + "error.failed_to_update_profile": "Echec de la mise a jour du profil", + "error.invalid_verification_code": "Code de verification non valide", + "error.verification_code_expired": "Le code de verification a expire", + "error.email_already_verified": "Email deja verifie", + "error.verification_failed": "Echec de la verification", + "error.failed_to_resend_verification": "Echec du renvoi de la verification", + "error.rate_limit_exceeded": "Trop de demandes de reinitialisation de mot de passe. Veuillez reessayer plus tard.", + "error.too_many_attempts": "Trop de tentatives. Veuillez demander un nouveau code.", + "error.invalid_reset_token": "Jeton de reinitialisation non valide ou expire", + "error.password_reset_failed": "Echec de la reinitialisation du mot de passe", + "error.apple_signin_not_configured": "La connexion Apple n'est pas configuree", + "error.apple_signin_failed": "Echec de la connexion Apple", + "error.invalid_apple_token": "Jeton d'identite Apple non valide", + + "error.invalid_task_id": "ID de tache non valide", + "error.invalid_residence_id": "ID de propriete non valide", + "error.invalid_contractor_id": "ID de prestataire non valide", + "error.invalid_document_id": "ID de document non valide", + "error.invalid_completion_id": "ID de completion non valide", + "error.invalid_user_id": "ID d'utilisateur non valide", + "error.invalid_notification_id": "ID de notification non valide", + "error.invalid_device_id": "ID d'appareil non valide", + + "error.task_not_found": "Tache non trouvee", + "error.residence_not_found": "Propriete non trouvee", + "error.contractor_not_found": "Prestataire non trouve", + "error.document_not_found": "Document non trouve", + "error.completion_not_found": "Completion de tache non trouvee", + "error.user_not_found": "Utilisateur non trouve", + "error.share_code_invalid": "Code de partage non valide", + "error.share_code_expired": "Le code de partage a expire", + + "error.task_access_denied": "Vous n'avez pas acces a cette tache", + "error.residence_access_denied": "Vous n'avez pas acces a cette propriete", + "error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire", + "error.document_access_denied": "Vous n'avez pas acces a ce document", + "error.not_residence_owner": "Seul le proprietaire peut effectuer cette action", + "error.cannot_remove_owner": "Impossible de retirer le proprietaire", + "error.user_already_member": "L'utilisateur est deja membre de cette propriete", + "error.properties_limit_reached": "Vous avez atteint le nombre maximum de proprietes pour votre abonnement", + + "error.task_already_cancelled": "La tache est deja annulee", + "error.task_already_archived": "La tache est deja archivee", + + "error.failed_to_parse_form": "Echec de l'analyse du formulaire", + "error.task_id_required": "task_id est requis", + "error.invalid_task_id_value": "task_id non valide", + "error.failed_to_upload_image": "Echec du telechargement de l'image", + "error.residence_id_required": "residence_id est requis", + "error.invalid_residence_id_value": "residence_id non valide", + "error.title_required": "Le titre est requis", + "error.failed_to_upload_file": "Echec du telechargement du fichier", + + "message.logged_out": "Deconnexion reussie", + "message.email_verified": "Email verifie avec succes", + "message.verification_email_sent": "Email de verification envoye", + "message.password_reset_email_sent": "Si un compte existe avec cet email, un code de reinitialisation a ete envoye.", + "message.reset_code_verified": "Code verifie avec succes", + "message.password_reset_success": "Mot de passe reinitialise avec succes. Veuillez vous connecter avec votre nouveau mot de passe.", + + "message.task_deleted": "Tache supprimee avec succes", + "message.task_in_progress": "Tache marquee comme en cours", + "message.task_cancelled": "Tache annulee", + "message.task_uncancelled": "Tache reactived", + "message.task_archived": "Tache archivee", + "message.task_unarchived": "Tache desarchivee", + "message.completion_deleted": "Completion supprimee avec succes", + + "message.residence_deleted": "Propriete supprimee avec succes", + "message.user_removed": "Utilisateur retire de la propriete", + "message.tasks_report_generated": "Rapport de taches genere avec succes", + "message.tasks_report_sent": "Rapport de taches genere et envoye a {{.Email}}", + "message.tasks_report_email_failed": "Rapport de taches genere mais l'email n'a pas pu etre envoye", + + "message.contractor_deleted": "Prestataire supprime avec succes", + + "message.document_deleted": "Document supprime avec succes", + "message.document_activated": "Document active", + "message.document_deactivated": "Document desactive", + + "message.notification_marked_read": "Notification marquée comme lue", + "message.all_notifications_marked_read": "Toutes les notifications marquées comme lues", + "message.device_removed": "Appareil supprimé", + + "message.subscription_upgraded": "Abonnement mis à niveau avec succès", + "message.subscription_cancelled": "Abonnement annulé. Vous conserverez les avantages Pro jusqu'à la fin de votre période de facturation.", + "message.subscription_restored": "Abonnement restauré avec succès", + + "message.file_deleted": "Fichier supprimé avec succès", + "message.static_data_refreshed": "Données statiques actualisées", + + "error.notification_not_found": "Notification non trouvée", + "error.invalid_platform": "Plateforme non valide", + + "error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé", + "error.receipt_data_required": "receipt_data est requis pour iOS", + "error.purchase_token_required": "purchase_token est requis pour Android", + + "error.no_file_provided": "Aucun fichier fourni", + + "error.failed_to_fetch_residence_types": "Échec de la récupération des types de propriété", + "error.failed_to_fetch_task_categories": "Échec de la récupération des catégories de tâches", + "error.failed_to_fetch_task_priorities": "Échec de la récupération des priorités de tâches", + "error.failed_to_fetch_task_frequencies": "Échec de la récupération des fréquences de tâches", + "error.failed_to_fetch_task_statuses": "Échec de la récupération des statuts de tâches", + "error.failed_to_fetch_contractor_specialties": "Échec de la récupération des spécialités des prestataires", + + "push.task_due_soon.title": "Tache Bientot Due", + "push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}", + "push.task_overdue.title": "Tache en Retard", + "push.task_overdue.body": "{{.TaskTitle}} est en retard", + "push.task_completed.title": "Tache Terminee", + "push.task_completed.body": "{{.UserName}} a termine {{.TaskTitle}}", + "push.task_assigned.title": "Nouvelle Tache Assignee", + "push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee", + "push.residence_shared.title": "Propriete Partagee", + "push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous", + + "email.welcome.subject": "Bienvenue sur Casera !", + "email.verification.subject": "Verifiez Votre Email", + "email.password_reset.subject": "Code de Reinitialisation de Mot de Passe", + "email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}", + + "lookup.residence_type.house": "Maison", + "lookup.residence_type.apartment": "Appartement", + "lookup.residence_type.condo": "Copropriete", + "lookup.residence_type.townhouse": "Maison de Ville", + "lookup.residence_type.mobile_home": "Mobil-home", + "lookup.residence_type.other": "Autre", + + "lookup.task_category.plumbing": "Plomberie", + "lookup.task_category.electrical": "Electricite", + "lookup.task_category.hvac": "Climatisation", + "lookup.task_category.appliances": "Electromenager", + "lookup.task_category.exterior": "Exterieur", + "lookup.task_category.interior": "Interieur", + "lookup.task_category.landscaping": "Jardinage", + "lookup.task_category.safety": "Securite", + "lookup.task_category.cleaning": "Nettoyage", + "lookup.task_category.pest_control": "Lutte Antiparasitaire", + "lookup.task_category.seasonal": "Saisonnier", + "lookup.task_category.other": "Autre", + + "lookup.task_priority.low": "Basse", + "lookup.task_priority.medium": "Moyenne", + "lookup.task_priority.high": "Haute", + "lookup.task_priority.urgent": "Urgente", + + "lookup.task_status.pending": "En Attente", + "lookup.task_status.in_progress": "En Cours", + "lookup.task_status.completed": "Terminee", + "lookup.task_status.cancelled": "Annulee", + "lookup.task_status.archived": "Archivee", + + "lookup.task_frequency.once": "Une Fois", + "lookup.task_frequency.daily": "Quotidien", + "lookup.task_frequency.weekly": "Hebdomadaire", + "lookup.task_frequency.biweekly": "Toutes les 2 Semaines", + "lookup.task_frequency.monthly": "Mensuel", + "lookup.task_frequency.quarterly": "Trimestriel", + "lookup.task_frequency.semiannually": "Tous les 6 Mois", + "lookup.task_frequency.annually": "Annuel", + + "lookup.contractor_specialty.plumber": "Plombier", + "lookup.contractor_specialty.electrician": "Electricien", + "lookup.contractor_specialty.hvac_technician": "Technicien CVC", + "lookup.contractor_specialty.handyman": "Bricoleur", + "lookup.contractor_specialty.landscaper": "Paysagiste", + "lookup.contractor_specialty.roofer": "Couvreur", + "lookup.contractor_specialty.painter": "Peintre", + "lookup.contractor_specialty.carpenter": "Menuisier", + "lookup.contractor_specialty.pest_control": "Desinsectisation", + "lookup.contractor_specialty.cleaning": "Nettoyage", + "lookup.contractor_specialty.pool_service": "Service Piscine", + "lookup.contractor_specialty.general_contractor": "Entrepreneur General", + "lookup.contractor_specialty.other": "Autre" +} diff --git a/internal/i18n/translations/pt.json b/internal/i18n/translations/pt.json new file mode 100644 index 0000000..46168bd --- /dev/null +++ b/internal/i18n/translations/pt.json @@ -0,0 +1,187 @@ +{ + "error.invalid_request_body": "Corpo da solicitacao invalido", + "error.invalid_credentials": "Credenciais invalidas", + "error.account_inactive": "A conta esta inativa", + "error.username_taken": "Nome de usuario ja em uso", + "error.email_taken": "Email ja registrado", + "error.email_already_taken": "Email ja em uso", + "error.registration_failed": "Falha no registro", + "error.not_authenticated": "Nao autenticado", + "error.failed_to_get_user": "Falha ao obter usuario", + "error.failed_to_update_profile": "Falha ao atualizar perfil", + "error.invalid_verification_code": "Codigo de verificacao invalido", + "error.verification_code_expired": "O codigo de verificacao expirou", + "error.email_already_verified": "Email ja verificado", + "error.verification_failed": "Falha na verificacao", + "error.failed_to_resend_verification": "Falha ao reenviar verificacao", + "error.rate_limit_exceeded": "Muitas solicitacoes de redefinicao de senha. Por favor, tente novamente mais tarde.", + "error.too_many_attempts": "Muitas tentativas. Por favor, solicite um novo codigo.", + "error.invalid_reset_token": "Token de redefinicao invalido ou expirado", + "error.password_reset_failed": "Falha na redefinicao de senha", + "error.apple_signin_not_configured": "O login com Apple nao esta configurado", + "error.apple_signin_failed": "Falha no login com Apple", + "error.invalid_apple_token": "Token de identidade Apple invalido", + + "error.invalid_task_id": "ID da tarefa invalido", + "error.invalid_residence_id": "ID da propriedade invalido", + "error.invalid_contractor_id": "ID do prestador invalido", + "error.invalid_document_id": "ID do documento invalido", + "error.invalid_completion_id": "ID de conclusao invalido", + "error.invalid_user_id": "ID do usuario invalido", + "error.invalid_notification_id": "ID da notificacao invalido", + "error.invalid_device_id": "ID do dispositivo invalido", + + "error.task_not_found": "Tarefa nao encontrada", + "error.residence_not_found": "Propriedade nao encontrada", + "error.contractor_not_found": "Prestador nao encontrado", + "error.document_not_found": "Documento nao encontrado", + "error.completion_not_found": "Conclusao da tarefa nao encontrada", + "error.user_not_found": "Usuario nao encontrado", + "error.share_code_invalid": "Codigo de compartilhamento invalido", + "error.share_code_expired": "O codigo de compartilhamento expirou", + + "error.task_access_denied": "Voce nao tem acesso a esta tarefa", + "error.residence_access_denied": "Voce nao tem acesso a esta propriedade", + "error.contractor_access_denied": "Voce nao tem acesso a este prestador", + "error.document_access_denied": "Voce nao tem acesso a este documento", + "error.not_residence_owner": "Apenas o proprietario pode realizar esta acao", + "error.cannot_remove_owner": "Nao e possivel remover o proprietario", + "error.user_already_member": "O usuario ja e membro desta propriedade", + "error.properties_limit_reached": "Voce atingiu o numero maximo de propriedades para sua assinatura", + + "error.task_already_cancelled": "A tarefa ja esta cancelada", + "error.task_already_archived": "A tarefa ja esta arquivada", + + "error.failed_to_parse_form": "Falha ao analisar o formulario", + "error.task_id_required": "task_id e obrigatorio", + "error.invalid_task_id_value": "task_id invalido", + "error.failed_to_upload_image": "Falha ao enviar imagem", + "error.residence_id_required": "residence_id e obrigatorio", + "error.invalid_residence_id_value": "residence_id invalido", + "error.title_required": "Titulo e obrigatorio", + "error.failed_to_upload_file": "Falha ao enviar arquivo", + + "message.logged_out": "Logout realizado com sucesso", + "message.email_verified": "Email verificado com sucesso", + "message.verification_email_sent": "Email de verificacao enviado", + "message.password_reset_email_sent": "Se existir uma conta com este email, um codigo de redefinicao foi enviado.", + "message.reset_code_verified": "Codigo verificado com sucesso", + "message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.", + + "message.task_deleted": "Tarefa excluida com sucesso", + "message.task_in_progress": "Tarefa marcada como em andamento", + "message.task_cancelled": "Tarefa cancelada", + "message.task_uncancelled": "Tarefa reativada", + "message.task_archived": "Tarefa arquivada", + "message.task_unarchived": "Tarefa desarquivada", + "message.completion_deleted": "Conclusao excluida com sucesso", + + "message.residence_deleted": "Propriedade excluida com sucesso", + "message.user_removed": "Usuario removido da propriedade", + "message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso", + "message.tasks_report_sent": "Relatorio de tarefas gerado e enviado para {{.Email}}", + "message.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado", + + "message.contractor_deleted": "Prestador excluido com sucesso", + + "message.document_deleted": "Documento excluido com sucesso", + "message.document_activated": "Documento ativado", + "message.document_deactivated": "Documento desativado", + + "message.notification_marked_read": "Notificação marcada como lida", + "message.all_notifications_marked_read": "Todas as notificações marcadas como lidas", + "message.device_removed": "Dispositivo removido", + + "message.subscription_upgraded": "Assinatura atualizada com sucesso", + "message.subscription_cancelled": "Assinatura cancelada. Você manterá os benefícios Pro até o final do seu período de faturamento.", + "message.subscription_restored": "Assinatura restaurada com sucesso", + + "message.file_deleted": "Arquivo excluído com sucesso", + "message.static_data_refreshed": "Dados estáticos atualizados", + + "error.notification_not_found": "Notificação não encontrada", + "error.invalid_platform": "Plataforma inválida", + + "error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado", + "error.receipt_data_required": "receipt_data é obrigatório para iOS", + "error.purchase_token_required": "purchase_token é obrigatório para Android", + + "error.no_file_provided": "Nenhum arquivo fornecido", + + "error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade", + "error.failed_to_fetch_task_categories": "Falha ao buscar categorias de tarefas", + "error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades de tarefas", + "error.failed_to_fetch_task_frequencies": "Falha ao buscar frequências de tarefas", + "error.failed_to_fetch_task_statuses": "Falha ao buscar status de tarefas", + "error.failed_to_fetch_contractor_specialties": "Falha ao buscar especialidades de prestadores", + + "push.task_due_soon.title": "Tarefa Proxima do Vencimento", + "push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}", + "push.task_overdue.title": "Tarefa Atrasada", + "push.task_overdue.body": "{{.TaskTitle}} esta atrasada", + "push.task_completed.title": "Tarefa Concluida", + "push.task_completed.body": "{{.UserName}} concluiu {{.TaskTitle}}", + "push.task_assigned.title": "Nova Tarefa Atribuida", + "push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce", + "push.residence_shared.title": "Propriedade Compartilhada", + "push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce", + + "email.welcome.subject": "Bem-vindo ao Casera!", + "email.verification.subject": "Verifique Seu Email", + "email.password_reset.subject": "Codigo de Redefinicao de Senha", + "email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}", + + "lookup.residence_type.house": "Casa", + "lookup.residence_type.apartment": "Apartamento", + "lookup.residence_type.condo": "Condominio", + "lookup.residence_type.townhouse": "Sobrado", + "lookup.residence_type.mobile_home": "Casa Movel", + "lookup.residence_type.other": "Outro", + + "lookup.task_category.plumbing": "Encanamento", + "lookup.task_category.electrical": "Eletrica", + "lookup.task_category.hvac": "Climatizacao", + "lookup.task_category.appliances": "Eletrodomesticos", + "lookup.task_category.exterior": "Exterior", + "lookup.task_category.interior": "Interior", + "lookup.task_category.landscaping": "Paisagismo", + "lookup.task_category.safety": "Seguranca", + "lookup.task_category.cleaning": "Limpeza", + "lookup.task_category.pest_control": "Controle de Pragas", + "lookup.task_category.seasonal": "Sazonal", + "lookup.task_category.other": "Outro", + + "lookup.task_priority.low": "Baixa", + "lookup.task_priority.medium": "Media", + "lookup.task_priority.high": "Alta", + "lookup.task_priority.urgent": "Urgente", + + "lookup.task_status.pending": "Pendente", + "lookup.task_status.in_progress": "Em Andamento", + "lookup.task_status.completed": "Concluida", + "lookup.task_status.cancelled": "Cancelada", + "lookup.task_status.archived": "Arquivada", + + "lookup.task_frequency.once": "Uma Vez", + "lookup.task_frequency.daily": "Diario", + "lookup.task_frequency.weekly": "Semanal", + "lookup.task_frequency.biweekly": "Quinzenal", + "lookup.task_frequency.monthly": "Mensal", + "lookup.task_frequency.quarterly": "Trimestral", + "lookup.task_frequency.semiannually": "Semestral", + "lookup.task_frequency.annually": "Anual", + + "lookup.contractor_specialty.plumber": "Encanador", + "lookup.contractor_specialty.electrician": "Eletricista", + "lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacao", + "lookup.contractor_specialty.handyman": "Faz-tudo", + "lookup.contractor_specialty.landscaper": "Paisagista", + "lookup.contractor_specialty.roofer": "Telhadista", + "lookup.contractor_specialty.painter": "Pintor", + "lookup.contractor_specialty.carpenter": "Carpinteiro", + "lookup.contractor_specialty.pest_control": "Controle de Pragas", + "lookup.contractor_specialty.cleaning": "Limpeza", + "lookup.contractor_specialty.pool_service": "Servico de Piscina", + "lookup.contractor_specialty.general_contractor": "Empreiteiro Geral", + "lookup.contractor_specialty.other": "Outro" +} diff --git a/internal/router/router.go b/internal/router/router.go index 116df38..ec5a09f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -11,6 +11,7 @@ import ( "github.com/treytartt/casera-api/internal/admin" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/handlers" + "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/repositories" @@ -48,6 +49,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine { r.Use(utils.GinRecovery()) r.Use(utils.GinLogger()) r.Use(corsMiddleware(cfg)) + r.Use(i18n.Middleware()) // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck)