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:
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"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/pexels"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
@@ -78,6 +79,11 @@ func run() error {
|
||||
}
|
||||
slog.Info("languages loaded", "count", len(locale.Languages))
|
||||
|
||||
if err := units.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load units: %w", err)
|
||||
}
|
||||
slog.Info("units loaded", "count", len(units.Records))
|
||||
|
||||
// Firebase auth
|
||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
backend/internal/units/handler.go
Normal file
28
backend/internal/units/handler.go
Normal 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})
|
||||
}
|
||||
73
backend/internal/units/registry.go
Normal file
73
backend/internal/units/registry.go
Normal 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
|
||||
}
|
||||
58
backend/migrations/014_create_units.sql
Normal file
58
backend/migrations/014_create_units.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE units (
|
||||
code VARCHAR(20) PRIMARY KEY,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO units (code, sort_order) VALUES
|
||||
('g', 1),
|
||||
('kg', 2),
|
||||
('ml', 3),
|
||||
('l', 4),
|
||||
('pcs', 5),
|
||||
('pack', 6);
|
||||
|
||||
CREATE TABLE unit_translations (
|
||||
unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE,
|
||||
lang VARCHAR(10) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (unit_code, lang)
|
||||
);
|
||||
INSERT INTO unit_translations (unit_code, lang, name) VALUES
|
||||
('g', 'ru', 'г'),
|
||||
('kg', 'ru', 'кг'),
|
||||
('ml', 'ru', 'мл'),
|
||||
('l', 'ru', 'л'),
|
||||
('pcs', 'ru', 'шт'),
|
||||
('pack', 'ru', 'уп');
|
||||
|
||||
-- Normalize products.unit from Russian display strings to English codes
|
||||
UPDATE products SET unit = 'g' WHERE unit = 'г';
|
||||
UPDATE products SET unit = 'kg' WHERE unit = 'кг';
|
||||
UPDATE products SET unit = 'ml' WHERE unit = 'мл';
|
||||
UPDATE products SET unit = 'l' WHERE unit = 'л';
|
||||
UPDATE products SET unit = 'pcs' WHERE unit = 'шт';
|
||||
UPDATE products SET unit = 'pack' WHERE unit = 'уп';
|
||||
-- Normalize any remaining unknown values
|
||||
UPDATE products SET unit = 'pcs' WHERE unit NOT IN (SELECT code FROM units);
|
||||
|
||||
-- Nullify unknown default_unit values in ingredient_mappings before adding FK
|
||||
UPDATE ingredient_mappings
|
||||
SET default_unit = NULL
|
||||
WHERE default_unit IS NOT NULL
|
||||
AND default_unit NOT IN (SELECT code FROM units);
|
||||
|
||||
-- Foreign key constraints
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT fk_product_unit
|
||||
FOREIGN KEY (unit) REFERENCES units(code);
|
||||
|
||||
ALTER TABLE ingredient_mappings
|
||||
ADD CONSTRAINT fk_default_unit
|
||||
FOREIGN KEY (default_unit) REFERENCES units(code);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_default_unit;
|
||||
ALTER TABLE products DROP CONSTRAINT IF EXISTS fk_product_unit;
|
||||
DROP TABLE IF EXISTS unit_translations;
|
||||
DROP TABLE IF EXISTS units;
|
||||
Reference in New Issue
Block a user