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