i18n: backend-localized lookups, suggestions, and static data (10 languages)
- 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:
@@ -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 ===
|
||||
|
||||
Reference in New Issue
Block a user