feat: dynamic units table with localized names via GET /units

- Add units + unit_translations tables with FK constraints on products and ingredient_mappings
- Normalize products.unit from Russian strings (г, кг) to English codes (g, kg)
- Load units at startup (in-memory registry) and serve via GET /units (language-aware)
- Replace hardcoded _units lists and _mapUnit() functions in Flutter with unitsProvider FutureProvider
- Re-fetches automatically when language changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 16:15:33 +02:00
parent e1fbe7b1a2
commit 55d01400b0
13 changed files with 259 additions and 86 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/food-ai/backend/internal/language"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/units"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recognition"
"github.com/food-ai/backend/internal/recommendation"
@@ -47,6 +48,7 @@ func NewRouter(
// Public
r.Get("/health", healthCheck(pool))
r.Get("/languages", language.List)
r.Get("/units", units.List)
r.Route("/auth", func(r chi.Router) {
r.Post("/login", authHandler.Login)
r.Post("/refresh", authHandler.Refresh)

View File

@@ -0,0 +1,28 @@
package units
import (
"encoding/json"
"net/http"
"github.com/food-ai/backend/internal/locale"
)
type unitItem struct {
Code string `json:"code"`
Name string `json:"name"`
}
// List handles GET /units — returns units with names in the requested language.
func List(w http.ResponseWriter, r *http.Request) {
lang := locale.FromContext(r.Context())
items := make([]unitItem, 0, len(Records))
for _, u := range Records {
name, ok := u.Translations[lang]
if !ok {
name = u.Code // fallback to English code
}
items = append(items, unitItem{Code: u.Code, Name: name})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"units": items})
}

View File

@@ -0,0 +1,73 @@
package units
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Record is a unit loaded from DB with all its translations.
type Record struct {
Code string
SortOrder int
Translations map[string]string // lang → localized name
}
// Records is the ordered list of active units, populated by LoadFromDB at startup.
var Records []Record
// LoadFromDB queries units + unit_translations and populates Records.
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
rows, err := pool.Query(ctx, `
SELECT u.code, u.sort_order, ut.lang, ut.name
FROM units u
LEFT JOIN unit_translations ut ON ut.unit_code = u.code
ORDER BY u.sort_order, ut.lang`)
if err != nil {
return fmt.Errorf("load units from db: %w", err)
}
defer rows.Close()
byCode := map[string]*Record{}
var order []string
for rows.Next() {
var code string
var sortOrder int
var lang, name *string
if err := rows.Scan(&code, &sortOrder, &lang, &name); err != nil {
return err
}
if _, ok := byCode[code]; !ok {
byCode[code] = &Record{Code: code, SortOrder: sortOrder, Translations: map[string]string{}}
order = append(order, code)
}
if lang != nil && name != nil {
byCode[code].Translations[*lang] = *name
}
}
if err := rows.Err(); err != nil {
return err
}
result := make([]Record, 0, len(order))
for _, code := range order {
result = append(result, *byCode[code])
}
Records = result
return nil
}
// NameFor returns the localized name for a unit code.
// Falls back to the code itself when no translation exists.
func NameFor(code, lang string) string {
for _, u := range Records {
if u.Code == code {
if name, ok := u.Translations[lang]; ok {
return name
}
return u.Code
}
}
return code
}