i18n: backend-localized lookups, suggestions, and static data (10 languages)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-04 20:54:54 -05:00
parent 25897e913e
commit 12de5a230a
23 changed files with 1671 additions and 703 deletions
+41 -25
View File
@@ -12,6 +12,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/i18n"
)
// CacheService provides Redis caching functionality
@@ -21,7 +22,7 @@ type CacheService struct {
var (
cacheInstance *CacheService
cacheOnce sync.Once
cacheOnce sync.Once
)
// NewCacheService creates a new cache service (thread-safe via sync.Once)
@@ -133,7 +134,6 @@ func (c *CacheService) Close() error {
return nil
}
// Static data cache helpers
const (
StaticDataKey = "static_data"
@@ -191,9 +191,11 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error {
LookupResidenceTypesKey,
LookupSpecialtiesKey,
LookupTaskTemplatesKey,
StaticDataKey, // Also invalidate the combined static data
SeededDataKey, // Invalidate unified seeded data
SeededDataETagKey, // Invalidate seeded data ETag
StaticDataKey, // Also invalidate the combined static data
}
// Per-locale seeded-data + ETag keys.
for _, lang := range i18n.SupportedLanguages {
keys = append(keys, seededDataKey(lang), seededDataETagKey(lang))
}
return c.Delete(ctx, keys...)
}
@@ -289,50 +291,64 @@ func (c *CacheService) InvalidateTaskTemplates(ctx context.Context) error {
return c.Delete(ctx, LookupTaskTemplatesKey, StaticDataKey)
}
// Unified seeded data cache helpers
// Unified seeded data cache helpers.
//
// The seeded-data payload is localized (lookup display_name + home-profile
// option labels), so the cache and ETag are namespaced per locale. Mixing
// locales under one key would let the first request poison every other
// language and make the ETag meaningless across locales.
const (
SeededDataKey = "seeded_data"
SeededDataETagKey = "seeded_data:etag"
SeededDataTTL = 24 * time.Hour
seededDataPrefix = "seeded_data:"
SeededDataTTL = 24 * time.Hour
)
// CacheSeededData caches the unified seeded data and generates an ETag
func (c *CacheService) CacheSeededData(ctx context.Context, data interface{}) (string, error) {
func seededDataKey(locale string) string { return seededDataPrefix + locale }
func seededDataETagKey(locale string) string { return seededDataPrefix + locale + ":etag" }
// CacheSeededData caches the unified seeded data for a locale and generates an
// ETag. The locale is folded into the ETag so a client switching languages
// always re-fetches rather than getting a stale 304.
func (c *CacheService) CacheSeededData(ctx context.Context, locale string, data interface{}) (string, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("failed to marshal seeded data: %w", err)
}
// Generate FNV-64a ETag from the JSON data (faster than MD5, non-cryptographic)
// FNV-64a ETag over locale + JSON (faster than MD5, non-cryptographic).
h := fnv.New64a()
h.Write([]byte(locale))
h.Write([]byte{0})
h.Write(jsonData)
etag := fmt.Sprintf("\"%x\"", h.Sum64())
// Store both the data and the ETag
if err := c.client.Set(ctx, SeededDataKey, jsonData, SeededDataTTL).Err(); err != nil {
if err := c.client.Set(ctx, seededDataKey(locale), jsonData, SeededDataTTL).Err(); err != nil {
return "", fmt.Errorf("failed to cache seeded data: %w", err)
}
if err := c.client.Set(ctx, SeededDataETagKey, etag, SeededDataTTL).Err(); err != nil {
if err := c.client.Set(ctx, seededDataETagKey(locale), etag, SeededDataTTL).Err(); err != nil {
return "", fmt.Errorf("failed to cache seeded data etag: %w", err)
}
return etag, nil
}
// GetCachedSeededData retrieves cached unified seeded data
func (c *CacheService) GetCachedSeededData(ctx context.Context, dest interface{}) error {
return c.Get(ctx, SeededDataKey, dest)
// GetCachedSeededData retrieves cached unified seeded data for a locale.
func (c *CacheService) GetCachedSeededData(ctx context.Context, locale string, dest interface{}) error {
return c.Get(ctx, seededDataKey(locale), dest)
}
// GetSeededDataETag retrieves the cached ETag for seeded data
func (c *CacheService) GetSeededDataETag(ctx context.Context) (string, error) {
return c.GetString(ctx, SeededDataETagKey)
// GetSeededDataETag retrieves the cached ETag for a locale's seeded data.
func (c *CacheService) GetSeededDataETag(ctx context.Context, locale string) (string, error) {
return c.GetString(ctx, seededDataETagKey(locale))
}
// InvalidateSeededData removes cached seeded data and its ETag
// InvalidateSeededData removes cached seeded data and ETags for every
// supported locale (lookup data is locale-independent at the source, so a
// change must clear all language variants).
func (c *CacheService) InvalidateSeededData(ctx context.Context) error {
return c.Delete(ctx, SeededDataKey, SeededDataETagKey)
keys := make([]string, 0, len(i18n.SupportedLanguages)*2)
for _, lang := range i18n.SupportedLanguages {
keys = append(keys, seededDataKey(lang), seededDataETagKey(lang))
}
return c.Delete(ctx, keys...)
}
// === User → Residence-IDs cache ===