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)