Skip to content

Commit 293b423

Browse files
authored
Merge pull request #71 from naiba/feat/sync-seeded-translations
feat: sync seeded entity translations when user changes locale
2 parents ef05eef + 3c7ce46 commit 293b423

File tree

9 files changed

+434
-17
lines changed

9 files changed

+434
-17
lines changed

server/internal/handlers/handlers_test.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7-
"os"
87
"mime/multipart"
98
"net/http"
109
"net/http/httptest"
1110
"net/textproto"
11+
"os"
1212
"strings"
1313
"testing"
1414

1515
"github.com/labstack/echo/v4"
1616
"github.com/naiba/bonds/internal/config"
1717
"github.com/naiba/bonds/internal/handlers"
18+
"github.com/naiba/bonds/internal/middleware"
1819
"github.com/naiba/bonds/internal/models"
1920
"github.com/naiba/bonds/internal/services"
2021
"github.com/naiba/bonds/internal/testutil"
@@ -933,7 +934,6 @@ func TestContactList_Pagination(t *testing.T) {
933934
}
934935
}
935936

936-
937937
func TestContactList_FilterArchived(t *testing.T) {
938938
ts := setupTestServer(t)
939939
token, _ := ts.registerTestUser(t, "cfilter@example.com")
@@ -1721,8 +1721,10 @@ func TestSearch_VaultIsolation(t *testing.T) {
17211721
}
17221722
resp := parseResponse(t, rec)
17231723
var searchResp struct {
1724-
Contacts []struct{ ID string `json:"id"` } `json:"contacts"`
1725-
Total int `json:"total"`
1724+
Contacts []struct {
1725+
ID string `json:"id"`
1726+
} `json:"contacts"`
1727+
Total int `json:"total"`
17261728
}
17271729
json.Unmarshal(resp.Data, &searchResp)
17281730
if len(searchResp.Contacts) != 0 {
@@ -4353,7 +4355,6 @@ func TestPersonalAccessToken_AuthWithExpiredPAT(t *testing.T) {
43534355
}
43544356
}
43554357

4356-
43574358
// ==================== ListForContact ====================
43584359

43594360
func TestListForContact_ReturnsOnlyAssigned(t *testing.T) {
@@ -4532,7 +4533,9 @@ func TestLifeEvent_CreateWithValidType(t *testing.T) {
45324533
t.Fatalf("create timeline: expected 201, got %d: %s", rec.Code, rec.Body.String())
45334534
}
45344535
resp := parseResponse(t, rec)
4535-
var teData struct{ ID uint `json:"id"` }
4536+
var teData struct {
4537+
ID uint `json:"id"`
4538+
}
45364539
if err := json.Unmarshal(resp.Data, &teData); err != nil {
45374540
t.Fatalf("failed to parse timeline: %v", err)
45384541
}
@@ -4643,7 +4646,9 @@ func TestLifeEvent_ListViaTimeline(t *testing.T) {
46434646
rec := ts.doRequest(http.MethodPost, "/api/vaults/"+vault.ID+"/contacts/"+contact.ID+"/timelineEvents",
46444647
`{"started_at":"2026-01-01T00:00:00Z","label":"Timeline"}`, token)
46454648
resp := parseResponse(t, rec)
4646-
var teData struct{ ID uint `json:"id"` }
4649+
var teData struct {
4650+
ID uint `json:"id"`
4651+
}
46474652
json.Unmarshal(resp.Data, &teData)
46484653

46494654
// Get valid type ID
@@ -5030,4 +5035,69 @@ func TestCompanyEmployee_AddAndRemove(t *testing.T) {
50305035
if len(companyAfter.Contacts) != 0 {
50315036
t.Errorf("expected 0 employees after remove, got %d", len(companyAfter.Contacts))
50325037
}
5033-
}
5038+
}
5039+
5040+
func (ts *testServer) doRequestWithLocale(method, path, body, token, locale string) *httptest.ResponseRecorder {
5041+
var req *http.Request
5042+
if body != "" {
5043+
req = httptest.NewRequest(method, path, strings.NewReader(body))
5044+
req.Header.Set("Content-Type", "application/json")
5045+
} else {
5046+
req = httptest.NewRequest(method, path, nil)
5047+
}
5048+
if token != "" {
5049+
req.Header.Set("Authorization", "Bearer "+token)
5050+
}
5051+
if locale != "" {
5052+
req.Header.Set("Accept-Language", locale)
5053+
}
5054+
rec := httptest.NewRecorder()
5055+
ts.e.ServeHTTP(rec, req)
5056+
return rec
5057+
}
5058+
5059+
func TestSyncTranslations_Success(t *testing.T) {
5060+
ts := setupTestServer(t)
5061+
ts.e.Use(middleware.Locale())
5062+
token, _ := ts.registerTestUser(t, "sync-handler@example.com")
5063+
5064+
rec := ts.doRequestWithLocale(http.MethodPost, "/api/settings/personalize/sync", "", token, "zh")
5065+
if rec.Code != http.StatusOK {
5066+
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
5067+
}
5068+
resp := parseResponse(t, rec)
5069+
if !resp.Success {
5070+
t.Fatal("expected success=true")
5071+
}
5072+
5073+
rec = ts.doRequestWithLocale(http.MethodGet, "/api/settings/personalize/genders", "", token, "zh")
5074+
if rec.Code != http.StatusOK {
5075+
t.Fatalf("list genders: expected 200, got %d: %s", rec.Code, rec.Body.String())
5076+
}
5077+
resp = parseResponse(t, rec)
5078+
var genders []struct {
5079+
Name string `json:"name"`
5080+
}
5081+
if err := json.Unmarshal(resp.Data, &genders); err != nil {
5082+
t.Fatalf("failed to parse genders: %v", err)
5083+
}
5084+
foundChinese := false
5085+
for _, g := range genders {
5086+
if g.Name == "男" {
5087+
foundChinese = true
5088+
break
5089+
}
5090+
}
5091+
if !foundChinese {
5092+
t.Fatalf("expected Chinese '男' after sync, got: %v", genders)
5093+
}
5094+
}
5095+
5096+
func TestSyncTranslations_Unauthorized(t *testing.T) {
5097+
ts := setupTestServer(t)
5098+
5099+
rec := ts.doRequest(http.MethodPost, "/api/settings/personalize/sync", "", "")
5100+
if rec.Code != http.StatusUnauthorized {
5101+
t.Fatalf("expected 401, got %d", rec.Code)
5102+
}
5103+
}

server/internal/handlers/personalize.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,24 @@ func (h *PersonalizeHandler) Delete(c echo.Context) error {
159159
}
160160
return response.NoContent(c)
161161
}
162+
163+
// SyncTranslations godoc
164+
//
165+
// @Summary Sync seeded entity translations
166+
// @Description Re-translate all default seeded labels to match the current locale (from Accept-Language header). Custom labels are not affected.
167+
// @Tags personalize
168+
// @Produce json
169+
// @Security BearerAuth
170+
// @Success 200 {object} response.APIResponse
171+
// @Failure 401 {object} response.APIResponse
172+
// @Failure 500 {object} response.APIResponse
173+
// @Router /settings/personalize/sync [post]
174+
func (h *PersonalizeHandler) SyncTranslations(c echo.Context) error {
175+
accountID := middleware.GetAccountID(c)
176+
locale := middleware.GetLocale(c)
177+
178+
if err := h.personalizeService.SyncAllTranslations(accountID, locale); err != nil {
179+
return response.InternalError(c, "err.failed_to_sync_translations")
180+
}
181+
return response.OK(c, nil)
182+
}

server/internal/handlers/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ func RegisterRoutes(e *echo.Echo, db *gorm.DB, cfg *config.Config, version strin
600600
notifGroup.GET("/:id/logs", notificationHandler.ListLogs)
601601

602602
personalizeGroup := settingsGroup.Group("/personalize", authMiddleware.RequireAdmin)
603+
personalizeGroup.POST("/sync", personalizeHandler.SyncTranslations)
603604
personalizeGroup.PUT("/currencies/:currencyId/toggle", currencyHandler.Toggle)
604605
personalizeGroup.POST("/currencies/enable-all", currencyHandler.EnableAll)
605606
personalizeGroup.DELETE("/currencies/disable-all", currencyHandler.DisableAll)

server/internal/middleware/locale.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ func Locale() echo.MiddlewareFunc {
2323
primary = strings.ToLower(primary)
2424
if primary == "zh" {
2525
lang = "zh"
26+
} else if primary == "es" {
27+
lang = "es"
2628
}
2729
}
2830
c.Set("locale", lang)

server/internal/services/personalize.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66

77
"github.com/naiba/bonds/internal/dto"
8+
"github.com/naiba/bonds/internal/i18n"
89
"gorm.io/gorm"
910
)
1011

@@ -176,3 +177,95 @@ func (s *PersonalizeService) ListTemplates(accountID string) ([]map[string]inter
176177
}
177178
return results, nil
178179
}
180+
181+
type syncableEntity struct {
182+
table string
183+
displayCol string
184+
keyCol string
185+
ownerCol string
186+
parentTable string
187+
parentJoinCol string
188+
}
189+
190+
var accountSyncEntities = []syncableEntity{
191+
{table: "genders", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
192+
{table: "pronouns", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
193+
{table: "address_types", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
194+
{table: "pet_categories", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
195+
{table: "contact_information_types", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
196+
{table: "relationship_group_types", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
197+
{table: "relationship_types", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id", parentTable: "relationship_group_types", parentJoinCol: "relationship_group_type_id"},
198+
{table: "relationship_types", displayCol: "name_reverse_relationship", keyCol: "name_reverse_relationship_translation_key", ownerCol: "account_id", parentTable: "relationship_group_types", parentJoinCol: "relationship_group_type_id"},
199+
{table: "call_reason_types", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id"},
200+
{table: "call_reasons", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id", parentTable: "call_reason_types", parentJoinCol: "call_reason_type_id"},
201+
{table: "religions", displayCol: "name", keyCol: "translation_key", ownerCol: "account_id"},
202+
{table: "group_types", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id"},
203+
{table: "group_type_roles", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id", parentTable: "group_types", parentJoinCol: "group_type_id"},
204+
{table: "emotions", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
205+
{table: "gift_occasions", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id"},
206+
{table: "gift_states", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id"},
207+
{table: "post_templates", displayCol: "label", keyCol: "label_translation_key", ownerCol: "account_id"},
208+
{table: "templates", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
209+
{table: "template_pages", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id", parentTable: "templates", parentJoinCol: "template_id"},
210+
{table: "modules", displayCol: "name", keyCol: "name_translation_key", ownerCol: "account_id"},
211+
}
212+
213+
var vaultSyncEntities = []syncableEntity{
214+
{table: "mood_tracking_parameters", displayCol: "label", keyCol: "label_translation_key", ownerCol: "vault_id"},
215+
{table: "life_event_categories", displayCol: "label", keyCol: "label_translation_key", ownerCol: "vault_id"},
216+
{table: "life_event_types", displayCol: "label", keyCol: "label_translation_key", ownerCol: "vault_id", parentTable: "life_event_categories", parentJoinCol: "life_event_category_id"},
217+
{table: "vault_quick_facts_templates", displayCol: "label", keyCol: "label_translation_key", ownerCol: "vault_id"},
218+
}
219+
220+
func (s *PersonalizeService) SyncAllTranslations(accountID, locale string) error {
221+
return s.db.Transaction(func(tx *gorm.DB) error {
222+
if err := syncEntities(tx, accountSyncEntities, accountID, locale); err != nil {
223+
return err
224+
}
225+
226+
var vaultIDs []string
227+
if err := tx.Table("vaults").Where("account_id = ?", accountID).Pluck("id", &vaultIDs).Error; err != nil {
228+
return err
229+
}
230+
for _, vaultID := range vaultIDs {
231+
if err := syncEntities(tx, vaultSyncEntities, vaultID, locale); err != nil {
232+
return err
233+
}
234+
}
235+
return nil
236+
})
237+
}
238+
239+
func syncEntities(tx *gorm.DB, entities []syncableEntity, ownerID, locale string) error {
240+
for _, e := range entities {
241+
var rows []struct {
242+
ID uint `gorm:"column:id"`
243+
Key string `gorm:"column:key"`
244+
}
245+
246+
query := tx.Table(e.table).Select("id, " + e.keyCol + " AS key").
247+
Where(e.keyCol + " IS NOT NULL AND " + e.keyCol + " != ''")
248+
249+
if e.parentTable != "" {
250+
query = query.Where(
251+
fmt.Sprintf("%s IN (SELECT id FROM %s WHERE %s = ?)", e.parentJoinCol, e.parentTable, e.ownerCol),
252+
ownerID,
253+
)
254+
} else {
255+
query = query.Where(e.ownerCol+" = ?", ownerID)
256+
}
257+
258+
if err := query.Find(&rows).Error; err != nil {
259+
return err
260+
}
261+
262+
for _, row := range rows {
263+
translated := i18n.T(locale, row.Key)
264+
if err := tx.Table(e.table).Where("id = ?", row.ID).
265+
Update(e.displayCol, translated).Error; err != nil {
266+
return err
267+
}
268+
}
269+
}
270+
return nil
271+
}

0 commit comments

Comments
 (0)