diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 162e5c6..d3e407b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) } diff --git a/backend/cmd/server/providers.go b/backend/cmd/server/providers.go new file mode 100644 index 0000000..8b8b7b9 --- /dev/null +++ b/backend/cmd/server/providers.go @@ -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) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go new file mode 100644 index 0000000..5b91933 --- /dev/null +++ b/backend/cmd/server/wire.go @@ -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 +} diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go new file mode 100644 index 0000000..01b9fb3 --- /dev/null +++ b/backend/cmd/server/wire_gen.go @@ -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 +} diff --git a/backend/go.mod b/backend/go.mod index 8d2e196..231aa05 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index fdec756..a11ec64 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/cuisine/handler.go b/backend/internal/cuisine/handler.go index 23c7326..38a44e7 100644 --- a/backend/internal/cuisine/handler.go +++ b/backend/internal/cuisine/handler.go @@ -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 + } + 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}) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).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}) } diff --git a/backend/internal/cuisine/registry.go b/backend/internal/cuisine/registry.go deleted file mode 100644 index 5974487..0000000 --- a/backend/internal/cuisine/registry.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/recognition/handler.go b/backend/internal/recognition/handler.go index 22bb363..0b16f89 100644 --- a/backend/internal/recognition/handler.go +++ b/backend/internal/recognition/handler.go @@ -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} } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 7820a0e..1d815b5 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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) diff --git a/backend/internal/tag/handler.go b/backend/internal/tag/handler.go index 5e4ab43..d5c6c2b 100644 --- a/backend/internal/tag/handler.go +++ b/backend/internal/tag/handler.go @@ -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 + } + 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}) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).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}) } diff --git a/backend/internal/tag/registry.go b/backend/internal/tag/registry.go deleted file mode 100644 index affbf5a..0000000 --- a/backend/internal/tag/registry.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/units/handler.go b/backend/internal/units/handler.go index ed26917..5f3795e 100644 --- a/backend/internal/units/handler.go +++ b/backend/internal/units/handler.go @@ -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 + } + 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}) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).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}) } diff --git a/backend/internal/units/registry.go b/backend/internal/units/registry.go deleted file mode 100644 index eaa91bd..0000000 --- a/backend/internal/units/registry.go +++ /dev/null @@ -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 -}