refactor: migrate DI to Wire, replace startup registries with on-demand DB queries
- Add google/wire; generate wire_gen.go from wire.go injector - Move all constructor wiring out of main.go into providers.go / wire.go - Export recognition.IngredientRepository (was unexported, blocked Wire binding) - Delete units/cuisine/tag registry.go files (global state + unused NameFor helpers) - Replace List handlers with NewListHandler(pool) using COALESCE SQL queries - Remove units/cuisine/tag LoadFromDB from server startup; locale.LoadFromDB kept (locale.Supported is used by language middleware on every request) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,177 +10,51 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/cuisine"
|
||||
"github.com/food-ai/backend/internal/database"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"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/recipe"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/server"
|
||||
"github.com/food-ai/backend/internal/tag"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
// jwtAdapter adapts auth.JWTManager to middleware.AccessTokenValidator.
|
||||
type jwtAdapter struct {
|
||||
jm *auth.JWTManager
|
||||
}
|
||||
|
||||
func (a *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
||||
claims, err := a.jm.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &middleware.TokenClaims{
|
||||
UserID: claims.UserID,
|
||||
Plan: claims.Plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
if runError := run(); runError != nil {
|
||||
slog.Error("fatal error", "err", runError)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
appConfig, configError := config.Load()
|
||||
if configError != nil {
|
||||
return fmt.Errorf("load config: %w", configError)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
applicationContext, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := database.NewPool(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to database: %w", err)
|
||||
pool, poolError := database.NewPool(applicationContext, appConfig.DatabaseURL)
|
||||
if poolError != nil {
|
||||
return fmt.Errorf("connect to database: %w", poolError)
|
||||
}
|
||||
defer pool.Close()
|
||||
slog.Info("connected to database")
|
||||
|
||||
if err := locale.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load languages: %w", err)
|
||||
if loadError := locale.LoadFromDB(applicationContext, pool); loadError != nil {
|
||||
return fmt.Errorf("load languages: %w", loadError)
|
||||
}
|
||||
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))
|
||||
|
||||
if err := cuisine.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load cuisines: %w", err)
|
||||
}
|
||||
slog.Info("cuisines loaded", "count", len(cuisine.Records))
|
||||
|
||||
if err := tag.LoadFromDB(ctx, pool); err != nil {
|
||||
return fmt.Errorf("load tags: %w", err)
|
||||
}
|
||||
slog.Info("tags loaded", "count", len(tag.Records))
|
||||
|
||||
// Firebase auth
|
||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init firebase auth: %w", err)
|
||||
router, initError := initRouter(appConfig, pool)
|
||||
if initError != nil {
|
||||
return fmt.Errorf("init router: %w", initError)
|
||||
}
|
||||
|
||||
// JWT manager
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessDuration, cfg.JWTRefreshDuration)
|
||||
|
||||
// User domain
|
||||
userRepo := user.NewRepository(pool)
|
||||
userService := user.NewService(userRepo)
|
||||
userHandler := user.NewHandler(userService)
|
||||
|
||||
// Auth domain
|
||||
authService := auth.NewService(firebaseAuth, userRepo, jwtManager)
|
||||
authHandler := auth.NewHandler(authService)
|
||||
|
||||
// Auth middleware
|
||||
authMW := middleware.Auth(&jwtAdapter{jm: jwtManager})
|
||||
|
||||
// External API clients
|
||||
geminiClient := gemini.NewClient(cfg.OpenAIAPIKey)
|
||||
pexelsClient := pexels.NewClient(cfg.PexelsAPIKey)
|
||||
|
||||
// Ingredient domain
|
||||
ingredientRepo := ingredient.NewRepository(pool)
|
||||
ingredientHandler := ingredient.NewHandler(ingredientRepo)
|
||||
|
||||
// Product domain
|
||||
productRepo := product.NewRepository(pool)
|
||||
productHandler := product.NewHandler(productRepo)
|
||||
|
||||
// Recognition domain
|
||||
recognitionHandler := recognition.NewHandler(geminiClient, ingredientRepo)
|
||||
|
||||
// Recommendation domain
|
||||
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
||||
|
||||
// Dish domain
|
||||
dishRepo := dish.NewRepository(pool)
|
||||
dishHandler := dish.NewHandler(dishRepo)
|
||||
|
||||
// Recipe domain
|
||||
recipeRepo := recipe.NewRepository(pool)
|
||||
recipeHandler := recipe.NewHandler(recipeRepo)
|
||||
|
||||
// Saved recipes domain
|
||||
savedRecipeRepo := savedrecipe.NewRepository(pool, dishRepo)
|
||||
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
|
||||
|
||||
// Menu domain
|
||||
menuRepo := menu.NewRepository(pool)
|
||||
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, dishRepo)
|
||||
|
||||
// Diary domain
|
||||
diaryRepo := diary.NewRepository(pool)
|
||||
diaryHandler := diary.NewHandler(diaryRepo)
|
||||
|
||||
// Home domain
|
||||
homeHandler := home.NewHandler(pool)
|
||||
|
||||
// Router
|
||||
router := server.NewRouter(
|
||||
pool,
|
||||
authHandler,
|
||||
userHandler,
|
||||
recommendationHandler,
|
||||
savedRecipeHandler,
|
||||
ingredientHandler,
|
||||
productHandler,
|
||||
recognitionHandler,
|
||||
menuHandler,
|
||||
diaryHandler,
|
||||
homeHandler,
|
||||
dishHandler,
|
||||
recipeHandler,
|
||||
authMW,
|
||||
cfg.AllowedOrigins,
|
||||
)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", appConfig.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 120 * time.Second, // menu generation can take ~60s
|
||||
@@ -188,17 +62,17 @@ func run() error {
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", err)
|
||||
slog.Info("server starting", "port", appConfig.Port)
|
||||
if serverError := httpServer.ListenAndServe(); serverError != nil && serverError != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", serverError)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
<-applicationContext.Done()
|
||||
slog.Info("shutting down...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
return httpServer.Shutdown(shutdownContext)
|
||||
}
|
||||
|
||||
204
backend/cmd/server/providers.go
Normal file
204
backend/cmd/server/providers.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/pexels"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/cuisine"
|
||||
"github.com/food-ai/backend/internal/server"
|
||||
"github.com/food-ai/backend/internal/tag"
|
||||
"github.com/food-ai/backend/internal/units"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Newtypes for config primitives — prevents Wire type collisions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Newtypes for list handlers — prevents Wire type collisions on http.HandlerFunc.
|
||||
type unitsListHandler http.HandlerFunc
|
||||
type cuisineListHandler http.HandlerFunc
|
||||
type tagListHandler http.HandlerFunc
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type geminiAPIKey string
|
||||
type pexelsAPIKey string
|
||||
type jwtSecret string
|
||||
type jwtAccessDuration time.Duration
|
||||
type jwtRefreshDuration time.Duration
|
||||
type allowedOrigins []string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newGeminiAPIKey(appConfig *config.Config) geminiAPIKey {
|
||||
return geminiAPIKey(appConfig.OpenAIAPIKey)
|
||||
}
|
||||
|
||||
func newPexelsAPIKey(appConfig *config.Config) pexelsAPIKey {
|
||||
return pexelsAPIKey(appConfig.PexelsAPIKey)
|
||||
}
|
||||
|
||||
func newJWTSecret(appConfig *config.Config) jwtSecret {
|
||||
return jwtSecret(appConfig.JWTSecret)
|
||||
}
|
||||
|
||||
func newJWTAccessDuration(appConfig *config.Config) jwtAccessDuration {
|
||||
return jwtAccessDuration(appConfig.JWTAccessDuration)
|
||||
}
|
||||
|
||||
func newJWTRefreshDuration(appConfig *config.Config) jwtRefreshDuration {
|
||||
return jwtRefreshDuration(appConfig.JWTRefreshDuration)
|
||||
}
|
||||
|
||||
func newAllowedOrigins(appConfig *config.Config) allowedOrigins {
|
||||
return allowedOrigins(appConfig.AllowedOrigins)
|
||||
}
|
||||
|
||||
// newFirebaseCredentialsFile is the only string that reaches NewFirebaseAuthOrNoop,
|
||||
// so no newtype is needed here.
|
||||
func newFirebaseCredentialsFile(appConfig *config.Config) string {
|
||||
return appConfig.FirebaseCredentialsFile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor wrappers for functions that accept primitive newtypes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newGeminiClient(key geminiAPIKey) *gemini.Client {
|
||||
return gemini.NewClient(string(key))
|
||||
}
|
||||
|
||||
func newPexelsClient(key pexelsAPIKey) *pexels.Client {
|
||||
return pexels.NewClient(string(key))
|
||||
}
|
||||
|
||||
func newJWTManager(
|
||||
secret jwtSecret,
|
||||
accessDuration jwtAccessDuration,
|
||||
refreshDuration jwtRefreshDuration,
|
||||
) *auth.JWTManager {
|
||||
return auth.NewJWTManager(string(secret), time.Duration(accessDuration), time.Duration(refreshDuration))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List handler providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newUnitsListHandler(pool *pgxpool.Pool) unitsListHandler {
|
||||
return unitsListHandler(units.NewListHandler(pool))
|
||||
}
|
||||
|
||||
func newCuisineListHandler(pool *pgxpool.Pool) cuisineListHandler {
|
||||
return cuisineListHandler(cuisine.NewListHandler(pool))
|
||||
}
|
||||
|
||||
func newTagListHandler(pool *pgxpool.Pool) tagListHandler {
|
||||
return tagListHandler(tag.NewListHandler(pool))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// newRouter wraps server.NewRouter to accept newtypes.
|
||||
func newRouter(
|
||||
pool *pgxpool.Pool,
|
||||
authHandler *auth.Handler,
|
||||
userHandler *user.Handler,
|
||||
recommendationHandler *recommendation.Handler,
|
||||
savedRecipeHandler *savedrecipe.Handler,
|
||||
ingredientHandler *ingredient.Handler,
|
||||
productHandler *product.Handler,
|
||||
recognitionHandler *recognition.Handler,
|
||||
menuHandler *menu.Handler,
|
||||
diaryHandler *diary.Handler,
|
||||
homeHandler *home.Handler,
|
||||
dishHandler *dish.Handler,
|
||||
recipeHandler *recipe.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
origins allowedOrigins,
|
||||
unitsHandler unitsListHandler,
|
||||
cuisineHandler cuisineListHandler,
|
||||
tagHandler tagListHandler,
|
||||
) http.Handler {
|
||||
return server.NewRouter(
|
||||
pool,
|
||||
authHandler,
|
||||
userHandler,
|
||||
recommendationHandler,
|
||||
savedRecipeHandler,
|
||||
ingredientHandler,
|
||||
productHandler,
|
||||
recognitionHandler,
|
||||
menuHandler,
|
||||
diaryHandler,
|
||||
homeHandler,
|
||||
dishHandler,
|
||||
recipeHandler,
|
||||
authMiddleware,
|
||||
[]string(origins),
|
||||
http.HandlerFunc(unitsHandler),
|
||||
http.HandlerFunc(cuisineHandler),
|
||||
http.HandlerFunc(tagHandler),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jwtAdapter — adapts *auth.JWTManager to middleware.AccessTokenValidator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type jwtAdapter struct {
|
||||
jwtManager *auth.JWTManager
|
||||
}
|
||||
|
||||
func newJWTAdapter(jm *auth.JWTManager) *jwtAdapter {
|
||||
return &jwtAdapter{jwtManager: jm}
|
||||
}
|
||||
|
||||
func (adapter *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
||||
claims, validationError := adapter.jwtManager.ValidateAccessToken(tokenStr)
|
||||
if validationError != nil {
|
||||
return nil, validationError
|
||||
}
|
||||
return &middleware.TokenClaims{
|
||||
UserID: claims.UserID,
|
||||
Plan: claims.Plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newAuthMiddleware wraps middleware.Auth for Wire injection.
|
||||
func newAuthMiddleware(validator middleware.AccessTokenValidator) func(http.Handler) http.Handler {
|
||||
return middleware.Auth(validator)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interface assertions (compile-time checks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil)
|
||||
var _ menu.PhotoSearcher = (*pexels.Client)(nil)
|
||||
var _ menu.UserLoader = (*user.Repository)(nil)
|
||||
var _ menu.ProductLister = (*product.Repository)(nil)
|
||||
var _ menu.RecipeSaver = (*dish.Repository)(nil)
|
||||
var _ recommendation.PhotoSearcher = (*pexels.Client)(nil)
|
||||
var _ recommendation.UserLoader = (*user.Repository)(nil)
|
||||
var _ recommendation.ProductLister = (*product.Repository)(nil)
|
||||
var _ recognition.IngredientRepository = (*ingredient.Repository)(nil)
|
||||
var _ user.UserRepository = (*user.Repository)(nil)
|
||||
111
backend/cmd/server/wire.go
Normal file
111
backend/cmd/server/wire.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//go:build wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/pexels"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/google/wire"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, error) {
|
||||
wire.Build(
|
||||
// Config extractors
|
||||
newGeminiAPIKey,
|
||||
newPexelsAPIKey,
|
||||
newJWTSecret,
|
||||
newJWTAccessDuration,
|
||||
newJWTRefreshDuration,
|
||||
newAllowedOrigins,
|
||||
newFirebaseCredentialsFile,
|
||||
|
||||
// Auth
|
||||
auth.NewFirebaseAuthOrNoop,
|
||||
newJWTManager,
|
||||
newJWTAdapter,
|
||||
newAuthMiddleware,
|
||||
auth.NewService,
|
||||
auth.NewHandler,
|
||||
|
||||
// User
|
||||
user.NewRepository,
|
||||
user.NewService,
|
||||
user.NewHandler,
|
||||
|
||||
// External clients
|
||||
newGeminiClient,
|
||||
newPexelsClient,
|
||||
|
||||
// Ingredient
|
||||
ingredient.NewRepository,
|
||||
ingredient.NewHandler,
|
||||
|
||||
// Product
|
||||
product.NewRepository,
|
||||
product.NewHandler,
|
||||
|
||||
// Dish
|
||||
dish.NewRepository,
|
||||
dish.NewHandler,
|
||||
|
||||
// Recipe
|
||||
recipe.NewRepository,
|
||||
recipe.NewHandler,
|
||||
|
||||
// Saved recipes
|
||||
savedrecipe.NewRepository,
|
||||
savedrecipe.NewHandler,
|
||||
|
||||
// Menu
|
||||
menu.NewRepository,
|
||||
menu.NewHandler,
|
||||
|
||||
// Diary
|
||||
diary.NewRepository,
|
||||
diary.NewHandler,
|
||||
|
||||
// Home
|
||||
home.NewHandler,
|
||||
|
||||
// Recognition & Recommendation
|
||||
recognition.NewHandler,
|
||||
recommendation.NewHandler,
|
||||
|
||||
// List handlers (DB-backed, injected into router)
|
||||
newUnitsListHandler,
|
||||
newCuisineListHandler,
|
||||
newTagListHandler,
|
||||
|
||||
// Router
|
||||
newRouter,
|
||||
|
||||
// Interface bindings
|
||||
wire.Bind(new(user.UserRepository), new(*user.Repository)),
|
||||
wire.Bind(new(menu.PhotoSearcher), new(*pexels.Client)),
|
||||
wire.Bind(new(menu.UserLoader), new(*user.Repository)),
|
||||
wire.Bind(new(menu.ProductLister), new(*product.Repository)),
|
||||
wire.Bind(new(menu.RecipeSaver), new(*dish.Repository)),
|
||||
wire.Bind(new(recommendation.PhotoSearcher), new(*pexels.Client)),
|
||||
wire.Bind(new(recommendation.UserLoader), new(*user.Repository)),
|
||||
wire.Bind(new(recommendation.ProductLister), new(*product.Repository)),
|
||||
wire.Bind(new(recognition.IngredientRepository), new(*ingredient.Repository)),
|
||||
wire.Bind(new(middleware.AccessTokenValidator), new(*jwtAdapter)),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
73
backend/cmd/server/wire_gen.go
Normal file
73
backend/cmd/server/wire_gen.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
"github.com/food-ai/backend/internal/ingredient"
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/product"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/recognition"
|
||||
"github.com/food-ai/backend/internal/recommendation"
|
||||
"github.com/food-ai/backend/internal/savedrecipe"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, error) {
|
||||
string2 := newFirebaseCredentialsFile(appConfig)
|
||||
tokenVerifier, err := auth.NewFirebaseAuthOrNoop(string2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository := user.NewRepository(pool)
|
||||
mainJwtSecret := newJWTSecret(appConfig)
|
||||
mainJwtAccessDuration := newJWTAccessDuration(appConfig)
|
||||
mainJwtRefreshDuration := newJWTRefreshDuration(appConfig)
|
||||
jwtManager := newJWTManager(mainJwtSecret, mainJwtAccessDuration, mainJwtRefreshDuration)
|
||||
service := auth.NewService(tokenVerifier, repository, jwtManager)
|
||||
handler := auth.NewHandler(service)
|
||||
userService := user.NewService(repository)
|
||||
userHandler := user.NewHandler(userService)
|
||||
mainGeminiAPIKey := newGeminiAPIKey(appConfig)
|
||||
client := newGeminiClient(mainGeminiAPIKey)
|
||||
mainPexelsAPIKey := newPexelsAPIKey(appConfig)
|
||||
pexelsClient := newPexelsClient(mainPexelsAPIKey)
|
||||
productRepository := product.NewRepository(pool)
|
||||
recommendationHandler := recommendation.NewHandler(client, pexelsClient, repository, productRepository)
|
||||
dishRepository := dish.NewRepository(pool)
|
||||
savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository)
|
||||
savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository)
|
||||
ingredientRepository := ingredient.NewRepository(pool)
|
||||
ingredientHandler := ingredient.NewHandler(ingredientRepository)
|
||||
productHandler := product.NewHandler(productRepository)
|
||||
recognitionHandler := recognition.NewHandler(client, ingredientRepository)
|
||||
menuRepository := menu.NewRepository(pool)
|
||||
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
|
||||
diaryRepository := diary.NewRepository(pool)
|
||||
diaryHandler := diary.NewHandler(diaryRepository)
|
||||
homeHandler := home.NewHandler(pool)
|
||||
dishHandler := dish.NewHandler(dishRepository)
|
||||
recipeRepository := recipe.NewRepository(pool)
|
||||
recipeHandler := recipe.NewHandler(recipeRepository)
|
||||
mainJwtAdapter := newJWTAdapter(jwtManager)
|
||||
v := newAuthMiddleware(mainJwtAdapter)
|
||||
mainAllowedOrigins := newAllowedOrigins(appConfig)
|
||||
mainUnitsListHandler := newUnitsListHandler(pool)
|
||||
mainCuisineListHandler := newCuisineListHandler(pool)
|
||||
mainTagListHandler := newTagListHandler(pool)
|
||||
httpHandler := newRouter(pool, handler, userHandler, recommendationHandler, savedrecipeHandler, ingredientHandler, productHandler, recognitionHandler, menuHandler, diaryHandler, homeHandler, dishHandler, recipeHandler, v, mainAllowedOrigins, mainUnitsListHandler, mainCuisineListHandler, mainTagListHandler)
|
||||
return httpHandler, nil
|
||||
}
|
||||
@@ -57,6 +57,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/wire v0.7.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect
|
||||
|
||||
@@ -117,6 +117,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
|
||||
@@ -2,9 +2,11 @@ package cuisine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type cuisineItem struct {
|
||||
@@ -12,17 +14,47 @@ type cuisineItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// List handles GET /cuisines — returns cuisines with names in the requested language.
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
lang := locale.FromContext(r.Context())
|
||||
items := make([]cuisineItem, 0, len(Records))
|
||||
for _, c := range Records {
|
||||
name, ok := c.Translations[lang]
|
||||
if !ok {
|
||||
name = c.Name
|
||||
// NewListHandler returns an http.HandlerFunc for GET /cuisines.
|
||||
// It queries the database directly, resolving translations via COALESCE.
|
||||
func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
lang := locale.FromContext(request.Context())
|
||||
|
||||
rows, queryError := pool.Query(request.Context(), `
|
||||
SELECT c.slug, COALESCE(ct.name, c.name) AS name
|
||||
FROM cuisines c
|
||||
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug AND ct.lang = $1
|
||||
ORDER BY c.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list cuisines", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
items = append(items, cuisineItem{Slug: c.Slug, Name: name})
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]cuisineItem, 0)
|
||||
for rows.Next() {
|
||||
var item cuisineItem
|
||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||
slog.Error("scan cuisine row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"cuisines": items})
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate cuisine rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load cuisines")
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"cuisines": items})
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) {
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(status)
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package cuisine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Record is a cuisine loaded from DB with all its translations.
|
||||
type Record struct {
|
||||
Slug string
|
||||
Name string // English canonical name
|
||||
SortOrder int
|
||||
// Translations maps lang code to localized name.
|
||||
Translations map[string]string
|
||||
}
|
||||
|
||||
// Records is the ordered list of cuisines, populated by LoadFromDB at startup.
|
||||
var Records []Record
|
||||
|
||||
// LoadFromDB queries cuisines + cuisine_translations and populates Records.
|
||||
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT c.slug, c.name, c.sort_order, ct.lang, ct.name
|
||||
FROM cuisines c
|
||||
LEFT JOIN cuisine_translations ct ON ct.cuisine_slug = c.slug
|
||||
ORDER BY c.sort_order, ct.lang`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cuisines from db: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bySlug := map[string]*Record{}
|
||||
var order []string
|
||||
for rows.Next() {
|
||||
var slug, engName string
|
||||
var sortOrder int
|
||||
var lang, name *string
|
||||
if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := bySlug[slug]; !ok {
|
||||
bySlug[slug] = &Record{
|
||||
Slug: slug,
|
||||
Name: engName,
|
||||
SortOrder: sortOrder,
|
||||
Translations: map[string]string{},
|
||||
}
|
||||
order = append(order, slug)
|
||||
}
|
||||
if lang != nil && name != nil {
|
||||
bySlug[slug].Translations[*lang] = *name
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]Record, 0, len(order))
|
||||
for _, slug := range order {
|
||||
result = append(result, *bySlug[slug])
|
||||
}
|
||||
Records = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// NameFor returns the localized name for a cuisine slug.
|
||||
// Falls back to the English canonical name.
|
||||
func NameFor(slug, lang string) string {
|
||||
for _, c := range Records {
|
||||
if c.Slug == slug {
|
||||
if name, ok := c.Translations[lang]; ok {
|
||||
return name
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
}
|
||||
return slug
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
)
|
||||
|
||||
// ingredientRepo is the subset of ingredient.Repository used by this handler.
|
||||
type ingredientRepo interface {
|
||||
// IngredientRepository is the subset of ingredient.Repository used by this handler.
|
||||
type IngredientRepository interface {
|
||||
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error)
|
||||
UpsertTranslation(ctx context.Context, id, lang, name string) error
|
||||
@@ -24,11 +24,11 @@ type ingredientRepo interface {
|
||||
// Handler handles POST /ai/* recognition endpoints.
|
||||
type Handler struct {
|
||||
gemini *gemini.Client
|
||||
ingredientRepo ingredientRepo
|
||||
ingredientRepo IngredientRepository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(geminiClient *gemini.Client, repo ingredientRepo) *Handler {
|
||||
func NewHandler(geminiClient *gemini.Client, repo IngredientRepository) *Handler {
|
||||
return &Handler{gemini: geminiClient, ingredientRepo: repo}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/cuisine"
|
||||
"github.com/food-ai/backend/internal/diary"
|
||||
"github.com/food-ai/backend/internal/dish"
|
||||
"github.com/food-ai/backend/internal/home"
|
||||
@@ -14,8 +13,6 @@ import (
|
||||
"github.com/food-ai/backend/internal/menu"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/recipe"
|
||||
"github.com/food-ai/backend/internal/tag"
|
||||
"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"
|
||||
@@ -41,6 +38,9 @@ func NewRouter(
|
||||
recipeHandler *recipe.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
allowedOrigins []string,
|
||||
unitsListHandler http.HandlerFunc,
|
||||
cuisineListHandler http.HandlerFunc,
|
||||
tagListHandler http.HandlerFunc,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -54,9 +54,9 @@ func NewRouter(
|
||||
// Public
|
||||
r.Get("/health", healthCheck(pool))
|
||||
r.Get("/languages", language.List)
|
||||
r.Get("/units", units.List)
|
||||
r.Get("/cuisines", cuisine.List)
|
||||
r.Get("/tags", tag.List)
|
||||
r.Get("/units", unitsListHandler)
|
||||
r.Get("/cuisines", cuisineListHandler)
|
||||
r.Get("/tags", tagListHandler)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", authHandler.Login)
|
||||
r.Post("/refresh", authHandler.Refresh)
|
||||
|
||||
@@ -2,9 +2,11 @@ package tag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type tagItem struct {
|
||||
@@ -12,17 +14,47 @@ type tagItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// List handles GET /tags — returns tags with names in the requested language.
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
lang := locale.FromContext(r.Context())
|
||||
items := make([]tagItem, 0, len(Records))
|
||||
for _, t := range Records {
|
||||
name, ok := t.Translations[lang]
|
||||
if !ok {
|
||||
name = t.Name
|
||||
// NewListHandler returns an http.HandlerFunc for GET /tags.
|
||||
// It queries the database directly, resolving translations via COALESCE.
|
||||
func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
lang := locale.FromContext(request.Context())
|
||||
|
||||
rows, queryError := pool.Query(request.Context(), `
|
||||
SELECT t.slug, COALESCE(tt.name, t.name) AS name
|
||||
FROM tags t
|
||||
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug AND tt.lang = $1
|
||||
ORDER BY t.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list tags", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
items = append(items, tagItem{Slug: t.Slug, Name: name})
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]tagItem, 0)
|
||||
for rows.Next() {
|
||||
var item tagItem
|
||||
if scanError := rows.Scan(&item.Slug, &item.Name); scanError != nil {
|
||||
slog.Error("scan tag row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tags": items})
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate tag rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load tags")
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"tags": items})
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) {
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(status)
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Record is a tag loaded from DB with all its translations.
|
||||
type Record struct {
|
||||
Slug string
|
||||
Name string // English canonical name
|
||||
SortOrder int
|
||||
Translations map[string]string // lang → localized name
|
||||
}
|
||||
|
||||
// Records is the ordered list of tags, populated by LoadFromDB at startup.
|
||||
var Records []Record
|
||||
|
||||
// LoadFromDB queries tags + tag_translations and populates Records.
|
||||
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.slug, t.name, t.sort_order, tt.lang, tt.name
|
||||
FROM tags t
|
||||
LEFT JOIN tag_translations tt ON tt.tag_slug = t.slug
|
||||
ORDER BY t.sort_order, tt.lang`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tags from db: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bySlug := map[string]*Record{}
|
||||
var order []string
|
||||
for rows.Next() {
|
||||
var slug, engName string
|
||||
var sortOrder int
|
||||
var lang, name *string
|
||||
if err := rows.Scan(&slug, &engName, &sortOrder, &lang, &name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := bySlug[slug]; !ok {
|
||||
bySlug[slug] = &Record{
|
||||
Slug: slug,
|
||||
Name: engName,
|
||||
SortOrder: sortOrder,
|
||||
Translations: map[string]string{},
|
||||
}
|
||||
order = append(order, slug)
|
||||
}
|
||||
if lang != nil && name != nil {
|
||||
bySlug[slug].Translations[*lang] = *name
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]Record, 0, len(order))
|
||||
for _, slug := range order {
|
||||
result = append(result, *bySlug[slug])
|
||||
}
|
||||
Records = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// NameFor returns the localized name for a tag slug.
|
||||
// Falls back to the English canonical name.
|
||||
func NameFor(slug, lang string) string {
|
||||
for _, t := range Records {
|
||||
if t.Slug == slug {
|
||||
if name, ok := t.Translations[lang]; ok {
|
||||
return name
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
}
|
||||
return slug
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package units
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type unitItem struct {
|
||||
@@ -12,17 +14,47 @@ type unitItem struct {
|
||||
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
|
||||
// NewListHandler returns an http.HandlerFunc for GET /units.
|
||||
// It queries the database directly, resolving translations via COALESCE.
|
||||
func NewListHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
lang := locale.FromContext(request.Context())
|
||||
|
||||
rows, queryError := pool.Query(request.Context(), `
|
||||
SELECT u.code, COALESCE(ut.name, u.code) AS name
|
||||
FROM units u
|
||||
LEFT JOIN unit_translations ut ON ut.unit_code = u.code AND ut.lang = $1
|
||||
ORDER BY u.sort_order`, lang)
|
||||
if queryError != nil {
|
||||
slog.Error("list units", "err", queryError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
items = append(items, unitItem{Code: u.Code, Name: name})
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]unitItem, 0)
|
||||
for rows.Next() {
|
||||
var item unitItem
|
||||
if scanError := rows.Scan(&item.Code, &item.Name); scanError != nil {
|
||||
slog.Error("scan unit row", "err", scanError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"units": items})
|
||||
items = append(items, item)
|
||||
}
|
||||
if rowsError := rows.Err(); rowsError != nil {
|
||||
slog.Error("iterate unit rows", "err", rowsError)
|
||||
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to load units")
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"units": items})
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorJSON(responseWriter http.ResponseWriter, status int, message string) {
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(status)
|
||||
_ = json.NewEncoder(responseWriter).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user