refactor: introduce internal/domain/ layer, rename model.go → entity.go
Move all business-logic packages from internal/ root into internal/domain/: auth, cuisine, diary, dish, home, ingredient, language, menu, product, recipe, recognition, recommendation, savedrecipe, tag, units, user Rename model.go → entity.go in packages that hold domain entities: diary, dish, home, ingredient, menu, product, recipe, savedrecipe, user Update all import paths accordingly (adapters, infra/server, cmd/server, tests). No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
106
backend/internal/domain/auth/handler.go
Normal file
106
backend/internal/domain/auth/handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
FirebaseToken string `json:"firebase_token"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirebaseToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(r.Context(), userID); err != nil {
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("failed to write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
69
backend/internal/domain/auth/jwt.go
Normal file
69
backend/internal/domain/auth/jwt.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
accessDuration time.Duration
|
||||
refreshDuration time.Duration
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, accessDuration, refreshDuration time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secret: []byte(secret),
|
||||
accessDuration: accessDuration,
|
||||
refreshDuration: refreshDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Plan: plan,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(j.secret)
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
|
||||
token := uuid.Must(uuid.NewV7()).String()
|
||||
expiresAt := time.Now().Add(j.refreshDuration)
|
||||
return token, expiresAt
|
||||
}
|
||||
|
||||
func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return j.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (j *JWTManager) AccessDuration() time.Duration {
|
||||
return j.accessDuration
|
||||
}
|
||||
11
backend/internal/domain/auth/mocks/token_verifier.go
Normal file
11
backend/internal/domain/auth/mocks/token_verifier.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package mocks
|
||||
|
||||
import "context"
|
||||
|
||||
type MockTokenVerifier struct {
|
||||
VerifyTokenFn func(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
|
||||
func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
|
||||
return m.VerifyTokenFn(ctx, idToken)
|
||||
}
|
||||
91
backend/internal/domain/auth/service.go
Normal file
91
backend/internal/domain/auth/service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/domain/user"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
tokenVerifier TokenVerifier
|
||||
userRepo user.UserRepository
|
||||
jwtManager *JWTManager
|
||||
}
|
||||
|
||||
func NewService(tokenVerifier TokenVerifier, userRepo user.UserRepository, jwtManager *JWTManager) *Service {
|
||||
return &Service{
|
||||
tokenVerifier: tokenVerifier,
|
||||
userRepo: userRepo,
|
||||
jwtManager: jwtManager,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *user.User `json:"user"`
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, firebaseToken string) (*LoginResponse, error) {
|
||||
uid, email, name, avatarURL, err := s.tokenVerifier.VerifyToken(ctx, firebaseToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify firebase token: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.userRepo.UpsertByFirebaseUID(ctx, uid, email, name, avatarURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert user: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, refreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
User: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
|
||||
u, err := s.userRepo.FindByRefreshToken(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
newRefreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, newRefreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &RefreshResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, userID string) error {
|
||||
return s.userRepo.ClearRefreshToken(ctx, userID)
|
||||
}
|
||||
8
backend/internal/domain/auth/token_verifier.go
Normal file
8
backend/internal/domain/auth/token_verifier.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// TokenVerifier abstracts token verification for testability.
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
60
backend/internal/domain/cuisine/handler.go
Normal file
60
backend/internal/domain/cuisine/handler.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package cuisine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type cuisineItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"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
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
37
backend/internal/domain/diary/entity.go
Normal file
37
backend/internal/domain/diary/entity.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package diary
|
||||
|
||||
import "time"
|
||||
|
||||
// Entry is a single meal diary record.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Portions float64 `json:"portions"`
|
||||
Calories *float64 `json:"calories,omitempty"`
|
||||
ProteinG *float64 `json:"protein_g,omitempty"`
|
||||
FatG *float64 `json:"fat_g,omitempty"`
|
||||
CarbsG *float64 `json:"carbs_g,omitempty"`
|
||||
Source string `json:"source"`
|
||||
DishID *string `json:"dish_id,omitempty"`
|
||||
RecipeID *string `json:"recipe_id,omitempty"`
|
||||
PortionG *float64 `json:"portion_g,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateRequest is the body for POST /diary.
|
||||
type CreateRequest struct {
|
||||
Date string `json:"date"`
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Portions float64 `json:"portions"`
|
||||
Calories *float64 `json:"calories"`
|
||||
ProteinG *float64 `json:"protein_g"`
|
||||
FatG *float64 `json:"fat_g"`
|
||||
CarbsG *float64 `json:"carbs_g"`
|
||||
Source string `json:"source"`
|
||||
DishID *string `json:"dish_id"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
PortionG *float64 `json:"portion_g"`
|
||||
}
|
||||
110
backend/internal/domain/diary/handler.go
Normal file
110
backend/internal/domain/diary/handler.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles diary endpoints.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// GetByDate handles GET /diary?date=YYYY-MM-DD
|
||||
func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" {
|
||||
writeError(w, http.StatusBadRequest, "date query parameter required (YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.repo.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
slog.Error("list diary by date", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load diary")
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []*Entry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// Create handles POST /diary
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Date == "" || req.Name == "" || req.MealType == "" {
|
||||
writeError(w, http.StatusBadRequest, "date, meal_type and name are required")
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := h.repo.Create(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("create diary entry", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, entry)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /diary/{id}
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "diary entry not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete diary entry", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
104
backend/internal/domain/diary/repository.go
Normal file
104
backend/internal/domain/diary/repository.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a diary entry does not exist for the user.
|
||||
var ErrNotFound = errors.New("diary entry not found")
|
||||
|
||||
// Repository handles persistence for meal diary entries.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
|
||||
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g,
|
||||
source, dish_id, recipe_id, portion_g, created_at
|
||||
FROM meal_diary
|
||||
WHERE user_id = $1 AND date = $2::date
|
||||
ORDER BY created_at ASC`, userID, date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list diary: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*Entry
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan diary entry: %w", err)
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Create inserts a new diary entry and returns the stored record.
|
||||
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) {
|
||||
portions := req.Portions
|
||||
if portions <= 0 {
|
||||
portions = 1
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "manual"
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
|
||||
userID, req.Date, req.MealType, req.Name, portions,
|
||||
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
||||
source, req.DishID, req.RecipeID, req.PortionG,
|
||||
)
|
||||
return scanEntry(row)
|
||||
}
|
||||
|
||||
// Delete removes a diary entry for the given user.
|
||||
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete diary entry: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanEntry(s scannable) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.Scan(
|
||||
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
|
||||
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
|
||||
&e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &e, err
|
||||
}
|
||||
96
backend/internal/domain/dish/entity.go
Normal file
96
backend/internal/domain/dish/entity.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package dish
|
||||
|
||||
import "time"
|
||||
|
||||
// Dish is a canonical dish record combining dish metadata with optional recipes.
|
||||
type Dish struct {
|
||||
ID string `json:"id"`
|
||||
CuisineSlug *string `json:"cuisine_slug"`
|
||||
CategorySlug *string `json:"category_slug"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Tags []string `json:"tags"`
|
||||
AvgRating float64 `json:"avg_rating"`
|
||||
ReviewCount int `json:"review_count"`
|
||||
Recipes []Recipe `json:"recipes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Recipe is a cooking variant attached to a Dish.
|
||||
type Recipe struct {
|
||||
ID string `json:"id"`
|
||||
DishID string `json:"dish_id"`
|
||||
Source string `json:"source"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
FatPerServing *float64 `json:"fat_per_serving"`
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
FiberPerServing *float64 `json:"fiber_per_serving"`
|
||||
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||
Steps []RecipeStep `json:"steps"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient row from recipe_ingredients.
|
||||
type RecipeIngredient struct {
|
||||
ID string `json:"id"`
|
||||
IngredientID *string `json:"ingredient_id"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
UnitCode *string `json:"unit_code"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step row from recipe_steps.
|
||||
type RecipeStep struct {
|
||||
ID string `json:"id"`
|
||||
StepNumber int `json:"step_number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
}
|
||||
|
||||
// CreateRequest is the body used to create a new dish + recipe at once.
|
||||
// Used when saving a Gemini-generated recommendation.
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CuisineSlug string `json:"cuisine_slug"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
Calories float64 `json:"calories_per_serving"`
|
||||
Protein float64 `json:"protein_per_serving"`
|
||||
Fat float64 `json:"fat_per_serving"`
|
||||
Carbs float64 `json:"carbs_per_serving"`
|
||||
Ingredients []IngredientInput `json:"ingredients"`
|
||||
Steps []StepInput `json:"steps"`
|
||||
}
|
||||
|
||||
// IngredientInput is a single ingredient in the create request.
|
||||
type IngredientInput struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
}
|
||||
|
||||
// StepInput is a single step in the create request.
|
||||
type StepInput struct {
|
||||
Number int `json:"number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
}
|
||||
67
backend/internal/domain/dish/handler.go
Normal file
67
backend/internal/domain/dish/handler.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package dish
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for dishes.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// List handles GET /dishes — returns all dishes (no recipe variants).
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
dishes, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list dishes", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to list dishes")
|
||||
return
|
||||
}
|
||||
if dishes == nil {
|
||||
dishes = []*Dish{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"dishes": dishes})
|
||||
}
|
||||
|
||||
// GetByID handles GET /dishes/{id} — returns a dish with all recipe variants.
|
||||
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dish, err := h.repo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("get dish", "id", id, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to get dish")
|
||||
return
|
||||
}
|
||||
if dish == nil {
|
||||
writeError(w, http.StatusNotFound, "dish not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dish)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
370
backend/internal/domain/dish/repository.go
Normal file
370
backend/internal/domain/dish/repository.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package dish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for dishes and their recipes.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// GetByID returns a dish with all its tag slugs and recipe variants.
|
||||
// Text is resolved for the language in ctx (English fallback).
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Dish, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT d.id,
|
||||
d.cuisine_slug, d.category_slug,
|
||||
COALESCE(dt.name, d.name) AS name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.avg_rating, d.review_count,
|
||||
d.created_at, d.updated_at
|
||||
FROM dishes d
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE d.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, id, lang)
|
||||
dish, err := scanDish(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get dish %s: %w", id, err)
|
||||
}
|
||||
|
||||
if err := r.loadTags(ctx, dish); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadRecipes(ctx, dish, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dish, nil
|
||||
}
|
||||
|
||||
// List returns all dishes with tag slugs (no recipe variants).
|
||||
// Text is resolved for the language in ctx.
|
||||
func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT d.id,
|
||||
d.cuisine_slug, d.category_slug,
|
||||
COALESCE(dt.name, d.name) AS name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.avg_rating, d.review_count,
|
||||
d.created_at, d.updated_at
|
||||
FROM dishes d
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $1
|
||||
ORDER BY d.updated_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dishes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dishes []*Dish
|
||||
for rows.Next() {
|
||||
d, err := scanDish(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan dish: %w", err)
|
||||
}
|
||||
dishes = append(dishes, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load tags for each dish.
|
||||
for _, d := range dishes {
|
||||
if err := r.loadTags(ctx, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return dishes, nil
|
||||
}
|
||||
|
||||
// Create creates a new dish + recipe row from a CreateRequest.
|
||||
// Returns the recipe ID to be used in menu_items or user_saved_recipes.
|
||||
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
// Resolve cuisine slug (empty string → NULL).
|
||||
cuisineSlug := nullableStr(req.CuisineSlug)
|
||||
|
||||
// Insert dish.
|
||||
var dishID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO dishes (cuisine_slug, name, description, image_url)
|
||||
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,''))
|
||||
RETURNING id`,
|
||||
cuisineSlug, req.Name, req.Description, req.ImageURL,
|
||||
).Scan(&dishID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert dish: %w", err)
|
||||
}
|
||||
|
||||
// Insert tags.
|
||||
for _, slug := range req.Tags {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
dishID, slug,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert dish tag %s: %w", slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert recipe.
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "ai"
|
||||
}
|
||||
difficulty := nullableStr(req.Difficulty)
|
||||
prepTime := nullableInt(req.PrepTimeMin)
|
||||
cookTime := nullableInt(req.CookTimeMin)
|
||||
servings := nullableInt(req.Servings)
|
||||
calories := nullableFloat(req.Calories)
|
||||
protein := nullableFloat(req.Protein)
|
||||
fat := nullableFloat(req.Fat)
|
||||
carbs := nullableFloat(req.Carbs)
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
dishID, source, difficulty, prepTime, cookTime, servings,
|
||||
calories, protein, fat, carbs,
|
||||
).Scan(&recipeID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert recipe: %w", err)
|
||||
}
|
||||
|
||||
// Insert recipe_ingredients.
|
||||
for i, ing := range req.Ingredients {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order)
|
||||
VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`,
|
||||
recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert ingredient %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert recipe_steps.
|
||||
for _, s := range req.Steps {
|
||||
num := s.Number
|
||||
if num <= 0 {
|
||||
num = 1
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
recipeID, num, s.TimerSeconds, s.Description,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert step %d: %w", num, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return recipeID, nil
|
||||
}
|
||||
|
||||
// loadTags fills d.Tags with the slugs from dish_tags.
|
||||
func (r *Repository) loadTags(ctx context.Context, d *Dish) error {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT tag_slug FROM dish_tags WHERE dish_id = $1 ORDER BY tag_slug`, d.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tags for dish %s: %w", d.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var slug string
|
||||
if err := rows.Scan(&slug); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Tags = append(d.Tags, slug)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadRecipes fills d.Recipes with all recipe variants for the dish,
|
||||
// including their relational ingredients and steps.
|
||||
func (r *Repository) loadRecipes(ctx context.Context, d *Dish, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT r.id, r.dish_id, r.source, r.difficulty,
|
||||
r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
|
||||
r.carbs_per_serving, r.fiber_per_serving,
|
||||
rt.notes,
|
||||
r.created_at, r.updated_at
|
||||
FROM recipes r
|
||||
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
|
||||
WHERE r.dish_id = $1
|
||||
ORDER BY r.created_at DESC`, d.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load recipes for dish %s: %w", d.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
rec, err := scanRecipe(rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
d.Recipes = append(d.Recipes, *rec)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load ingredients and steps for each recipe.
|
||||
for i := range d.Recipes {
|
||||
if err := r.loadIngredients(ctx, &d.Recipes[i], lang); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.loadSteps(ctx, &d.Recipes[i], lang); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIngredients fills rec.Ingredients from recipe_ingredients.
|
||||
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT ri.id, ri.ingredient_id,
|
||||
COALESCE(rit.name, ri.name) AS name,
|
||||
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
|
||||
FROM recipe_ingredients ri
|
||||
LEFT JOIN recipe_ingredient_translations rit
|
||||
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||
WHERE ri.recipe_id = $1
|
||||
ORDER BY ri.sort_order`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var ing RecipeIngredient
|
||||
if err := rows.Scan(
|
||||
&ing.ID, &ing.IngredientID, &ing.Name,
|
||||
&ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan ingredient: %w", err)
|
||||
}
|
||||
rec.Ingredients = append(rec.Ingredients, ing)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadSteps fills rec.Steps from recipe_steps.
|
||||
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rs.id, rs.step_number,
|
||||
COALESCE(rst.description, rs.description) AS description,
|
||||
rs.timer_seconds, rs.image_url
|
||||
FROM recipe_steps rs
|
||||
LEFT JOIN recipe_step_translations rst
|
||||
ON rst.step_id = rs.id AND rst.lang = $2
|
||||
WHERE rs.recipe_id = $1
|
||||
ORDER BY rs.step_number`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var s RecipeStep
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan step: %w", err)
|
||||
}
|
||||
rec.Steps = append(rec.Steps, s)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanDish(s scannable) (*Dish, error) {
|
||||
var d Dish
|
||||
err := s.Scan(
|
||||
&d.ID, &d.CuisineSlug, &d.CategorySlug,
|
||||
&d.Name, &d.Description, &d.ImageURL,
|
||||
&d.AvgRating, &d.ReviewCount,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Tags = []string{}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func scanRecipe(s scannable) (*Recipe, error) {
|
||||
var rec Recipe
|
||||
err := s.Scan(
|
||||
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
|
||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
|
||||
&rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&rec.Notes,
|
||||
&rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = []RecipeIngredient{}
|
||||
rec.Steps = []RecipeStep{}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// --- null helpers ---
|
||||
|
||||
func nullableStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func nullableInt(n int) *int {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func nullableFloat(f float64) *float64 {
|
||||
if f <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
39
backend/internal/domain/home/entity.go
Normal file
39
backend/internal/domain/home/entity.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package home
|
||||
|
||||
// Summary is the response for GET /home/summary.
|
||||
type Summary struct {
|
||||
Today TodaySummary `json:"today"`
|
||||
ExpiringSoon []ExpiringSoon `json:"expiring_soon"`
|
||||
Recommendations []Recommendation `json:"recommendations"`
|
||||
}
|
||||
|
||||
// TodaySummary contains the day-level overview.
|
||||
type TodaySummary struct {
|
||||
Date string `json:"date"`
|
||||
DailyGoal int `json:"daily_goal"`
|
||||
LoggedCalories float64 `json:"logged_calories"`
|
||||
Plan []MealPlan `json:"plan"`
|
||||
}
|
||||
|
||||
// MealPlan is a single planned meal slot for today.
|
||||
type MealPlan struct {
|
||||
MealType string `json:"meal_type"`
|
||||
RecipeTitle *string `json:"recipe_title"`
|
||||
RecipeImageURL *string `json:"recipe_image_url"`
|
||||
Calories *float64 `json:"calories"`
|
||||
}
|
||||
|
||||
// ExpiringSoon is a product expiring within 3 days.
|
||||
type ExpiringSoon struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays int `json:"expires_in_days"`
|
||||
Quantity string `json:"quantity"`
|
||||
}
|
||||
|
||||
// Recommendation is a saved recipe shown on the home screen.
|
||||
type Recommendation struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Calories *float64 `json:"calories"`
|
||||
}
|
||||
242
backend/internal/domain/home/handler.go
Normal file
242
backend/internal/domain/home/handler.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Handler handles GET /home/summary.
|
||||
type Handler struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(pool *pgxpool.Pool) *Handler {
|
||||
return &Handler{pool: pool}
|
||||
}
|
||||
|
||||
// GetSummary handles GET /home/summary.
|
||||
func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
now := time.Now().UTC()
|
||||
todayStr := now.Format("2006-01-02")
|
||||
|
||||
// ISO day of week: Monday=1 … Sunday=7.
|
||||
wd := int(now.Weekday())
|
||||
if wd == 0 {
|
||||
wd = 7
|
||||
}
|
||||
|
||||
// Monday of the current ISO week.
|
||||
year, week := now.ISOWeek()
|
||||
weekStart := mondayOfISOWeek(year, week).Format("2006-01-02")
|
||||
|
||||
// Daily calorie goal from user profile.
|
||||
dailyGoal := h.getDailyGoal(ctx, userID)
|
||||
|
||||
summary := Summary{
|
||||
Today: TodaySummary{
|
||||
Date: todayStr,
|
||||
DailyGoal: dailyGoal,
|
||||
LoggedCalories: h.getLoggedCalories(ctx, userID, todayStr),
|
||||
Plan: h.getTodayPlan(ctx, userID, weekStart, wd),
|
||||
},
|
||||
ExpiringSoon: h.getExpiringSoon(ctx, userID),
|
||||
Recommendations: h.getRecommendations(ctx, userID),
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// getDailyGoal returns the user's daily_calories setting (default 2000).
|
||||
func (h *Handler) getDailyGoal(ctx context.Context, userID string) int {
|
||||
var goal int
|
||||
err := h.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(daily_calories, 2000) FROM users WHERE id = $1`,
|
||||
userID,
|
||||
).Scan(&goal)
|
||||
if err != nil {
|
||||
slog.Warn("home: get daily goal", "user_id", userID, "err", err)
|
||||
return 2000
|
||||
}
|
||||
return goal
|
||||
}
|
||||
|
||||
// getLoggedCalories returns total calories logged in meal_diary for today.
|
||||
func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) float64 {
|
||||
var total float64
|
||||
_ = h.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(SUM(calories * portions), 0)
|
||||
FROM meal_diary
|
||||
WHERE user_id = $1 AND date::text = $2`,
|
||||
userID, date,
|
||||
).Scan(&total)
|
||||
return total
|
||||
}
|
||||
|
||||
// getTodayPlan returns the three meal slots planned for today.
|
||||
// If no menu exists, all three slots are returned with nil recipe fields.
|
||||
func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan {
|
||||
const q = `
|
||||
SELECT mi.meal_type,
|
||||
sr.title,
|
||||
sr.image_url,
|
||||
(sr.nutrition->>'calories')::float
|
||||
FROM menu_plans mp
|
||||
JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||||
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||||
WHERE mp.user_id = $1
|
||||
AND mp.week_start::text = $2
|
||||
AND mi.day_of_week = $3`
|
||||
|
||||
rows, err := h.pool.Query(ctx, q, userID, weekStart, dow)
|
||||
if err != nil {
|
||||
slog.Warn("home: get today plan", "err", err)
|
||||
return defaultPlan()
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := map[string]MealPlan{}
|
||||
for rows.Next() {
|
||||
var mealType string
|
||||
var title, imageURL *string
|
||||
var calories *float64
|
||||
if err := rows.Scan(&mealType, &title, &imageURL, &calories); err != nil {
|
||||
continue
|
||||
}
|
||||
found[mealType] = MealPlan{
|
||||
MealType: mealType,
|
||||
RecipeTitle: title,
|
||||
RecipeImageURL: imageURL,
|
||||
Calories: calories,
|
||||
}
|
||||
}
|
||||
|
||||
// Always return all three meal types in order.
|
||||
mealTypes := []string{"breakfast", "lunch", "dinner"}
|
||||
plan := make([]MealPlan, 0, 3)
|
||||
for _, mt := range mealTypes {
|
||||
if mp, ok := found[mt]; ok {
|
||||
plan = append(plan, mp)
|
||||
} else {
|
||||
plan = append(plan, MealPlan{MealType: mt})
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// defaultPlan returns three empty meal slots.
|
||||
func defaultPlan() []MealPlan {
|
||||
return []MealPlan{
|
||||
{MealType: "breakfast"},
|
||||
{MealType: "lunch"},
|
||||
{MealType: "dinner"},
|
||||
}
|
||||
}
|
||||
|
||||
// getExpiringSoon returns products expiring within the next 3 days.
|
||||
func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []ExpiringSoon {
|
||||
rows, err := h.pool.Query(ctx, `
|
||||
WITH p AS (
|
||||
SELECT name, quantity, unit,
|
||||
(added_at + storage_days * INTERVAL '1 day') AS expires_at
|
||||
FROM products
|
||||
WHERE user_id = $1
|
||||
)
|
||||
SELECT name, quantity, unit,
|
||||
GREATEST(0, EXTRACT(EPOCH FROM (expires_at - now())) / 86400)::int
|
||||
FROM p
|
||||
WHERE expires_at > now()
|
||||
AND expires_at <= now() + INTERVAL '3 days'
|
||||
ORDER BY expires_at
|
||||
LIMIT 5`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("home: get expiring soon", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ExpiringSoon
|
||||
for rows.Next() {
|
||||
var name, unit string
|
||||
var quantity float64
|
||||
var daysLeft int
|
||||
if err := rows.Scan(&name, &quantity, &unit, &daysLeft); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, ExpiringSoon{
|
||||
Name: name,
|
||||
ExpiresInDays: daysLeft,
|
||||
Quantity: fmt.Sprintf("%.0f %s", quantity, unit),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getRecommendations returns the 3 most recently generated recipe recommendations.
|
||||
func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation {
|
||||
rows, err := h.pool.Query(ctx, `
|
||||
SELECT id, title, COALESCE(image_url, ''),
|
||||
(nutrition->>'calories')::float
|
||||
FROM saved_recipes
|
||||
WHERE user_id = $1 AND source = 'recommendation'
|
||||
ORDER BY saved_at DESC
|
||||
LIMIT 3`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("home: get recommendations", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []Recommendation
|
||||
for rows.Next() {
|
||||
var rec Recommendation
|
||||
var cal *float64
|
||||
if err := rows.Scan(&rec.ID, &rec.Title, &rec.ImageURL, &cal); err != nil {
|
||||
continue
|
||||
}
|
||||
rec.Calories = cal
|
||||
result = append(result, rec)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mondayOfISOWeek returns the Monday of the given ISO year/week.
|
||||
func mondayOfISOWeek(year, week int) time.Time {
|
||||
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC)
|
||||
wd := int(jan4.Weekday())
|
||||
if wd == 0 {
|
||||
wd = 7
|
||||
}
|
||||
monday1 := jan4.AddDate(0, 0, 1-wd)
|
||||
return monday1.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
29
backend/internal/domain/ingredient/entity.go
Normal file
29
backend/internal/domain/ingredient/entity.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IngredientMapping is the canonical ingredient record used to link
|
||||
// user products and recipe ingredients.
|
||||
// CanonicalName holds the content for the language resolved at query time
|
||||
// (English by default, or from ingredient_translations when available).
|
||||
type IngredientMapping struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalName string `json:"canonical_name"`
|
||||
Aliases json.RawMessage `json:"aliases"` // []string, populated by read queries
|
||||
Category *string `json:"category"`
|
||||
CategoryName *string `json:"category_name"` // localized category display name
|
||||
DefaultUnit *string `json:"default_unit"`
|
||||
|
||||
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||
FatPer100g *float64 `json:"fat_per_100g"`
|
||||
CarbsPer100g *float64 `json:"carbs_per_100g"`
|
||||
FiberPer100g *float64 `json:"fiber_per_100g"`
|
||||
|
||||
StorageDays *int `json:"storage_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
51
backend/internal/domain/ingredient/handler.go
Normal file
51
backend/internal/domain/ingredient/handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Handler handles ingredient HTTP requests.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// Search handles GET /ingredients/search?q=&limit=10.
|
||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if s := r.URL.Query().Get("limit"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mappings, err := h.repo.Search(r.Context(), q, limit)
|
||||
if err != nil {
|
||||
slog.Error("search ingredients", "q", q, "err", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"search failed"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if mappings == nil {
|
||||
mappings = []*IngredientMapping{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mappings)
|
||||
}
|
||||
301
backend/internal/domain/ingredient/repository.go
Normal file
301
backend/internal/domain/ingredient/repository.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for ingredients and their translations.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates an ingredient (English canonical content).
|
||||
// Conflict is resolved on canonical_name.
|
||||
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
||||
query := `
|
||||
INSERT INTO ingredients (
|
||||
canonical_name,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (canonical_name) DO UPDATE SET
|
||||
category = EXCLUDED.category,
|
||||
default_unit = EXCLUDED.default_unit,
|
||||
calories_per_100g = EXCLUDED.calories_per_100g,
|
||||
protein_per_100g = EXCLUDED.protein_per_100g,
|
||||
fat_per_100g = EXCLUDED.fat_per_100g,
|
||||
carbs_per_100g = EXCLUDED.carbs_per_100g,
|
||||
fiber_per_100g = EXCLUDED.fiber_per_100g,
|
||||
storage_days = EXCLUDED.storage_days,
|
||||
updated_at = now()
|
||||
RETURNING id, canonical_name, category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days, created_at, updated_at`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
m.CanonicalName,
|
||||
m.Category, m.DefaultUnit,
|
||||
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
|
||||
m.StorageDays,
|
||||
)
|
||||
return scanMappingWrite(row)
|
||||
}
|
||||
|
||||
// GetByID returns an ingredient by UUID.
|
||||
// CanonicalName and aliases are resolved for the language stored in ctx.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
query := `
|
||||
SELECT im.id,
|
||||
COALESCE(it.name, im.canonical_name) AS canonical_name,
|
||||
im.category,
|
||||
COALESCE(ict.name, im.category) AS category_name,
|
||||
im.default_unit,
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at,
|
||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||
FROM ingredients im
|
||||
LEFT JOIN ingredient_translations it
|
||||
ON it.ingredient_id = im.id AND it.lang = $2
|
||||
LEFT JOIN ingredient_category_translations ict
|
||||
ON ict.category_slug = im.category AND ict.lang = $2
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases
|
||||
FROM ingredient_aliases ia
|
||||
WHERE ia.ingredient_id = im.id AND ia.lang = $2
|
||||
) al ON true
|
||||
WHERE im.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, id, lang)
|
||||
m, err := scanMappingRead(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// FuzzyMatch finds the single best matching ingredient for a given name.
|
||||
// Searches both English and translated names for the language in ctx.
|
||||
// Returns nil, nil when no match is found.
|
||||
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||
results, err := r.Search(ctx, name, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
// Search finds ingredients matching the query string.
|
||||
// Searches aliases table and translated names for the language in ctx.
|
||||
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
lang := locale.FromContext(ctx)
|
||||
q := `
|
||||
SELECT im.id,
|
||||
COALESCE(it.name, im.canonical_name) AS canonical_name,
|
||||
im.category,
|
||||
COALESCE(ict.name, im.category) AS category_name,
|
||||
im.default_unit,
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at,
|
||||
COALESCE(al.aliases, '[]'::json) AS aliases
|
||||
FROM ingredients im
|
||||
LEFT JOIN ingredient_translations it
|
||||
ON it.ingredient_id = im.id AND it.lang = $3
|
||||
LEFT JOIN ingredient_category_translations ict
|
||||
ON ict.category_slug = im.category AND ict.lang = $3
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases
|
||||
FROM ingredient_aliases ia
|
||||
WHERE ia.ingredient_id = im.id AND ia.lang = $3
|
||||
) al ON true
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM ingredient_aliases ia
|
||||
WHERE ia.ingredient_id = im.id
|
||||
AND (ia.lang = $3 OR ia.lang = 'en')
|
||||
AND ia.alias ILIKE '%' || $1 || '%'
|
||||
)
|
||||
OR im.canonical_name ILIKE '%' || $1 || '%'
|
||||
OR it.name ILIKE '%' || $1 || '%'
|
||||
OR similarity(COALESCE(it.name, im.canonical_name), $1) > 0.3
|
||||
ORDER BY similarity(COALESCE(it.name, im.canonical_name), $1) DESC
|
||||
LIMIT $2`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, query, limit, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search ingredients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectMappingsRead(rows)
|
||||
}
|
||||
|
||||
// Count returns the total number of ingredients.
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count ingredients: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListMissingTranslation returns ingredients that have no translation for the
|
||||
// given language, ordered by id.
|
||||
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) {
|
||||
query := `
|
||||
SELECT im.id, im.canonical_name,
|
||||
im.category, im.default_unit,
|
||||
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||
im.storage_days, im.created_at, im.updated_at
|
||||
FROM ingredients im
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ingredient_translations it
|
||||
WHERE it.ingredient_id = im.id AND it.lang = $3
|
||||
)
|
||||
ORDER BY im.id
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, limit, offset, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectMappingsWrite(rows)
|
||||
}
|
||||
|
||||
// UpsertTranslation inserts or replaces a name translation for an ingredient.
|
||||
func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string) error {
|
||||
query := `
|
||||
INSERT INTO ingredient_translations (ingredient_id, lang, name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (ingredient_id, lang) DO UPDATE SET name = EXCLUDED.name`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, id, lang, name); err != nil {
|
||||
return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertAliases inserts aliases for a given ingredient and language.
|
||||
// Each alias is inserted with ON CONFLICT DO NOTHING, so duplicates are skipped.
|
||||
func (r *Repository) UpsertAliases(ctx context.Context, id, lang string, aliases []string) error {
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, alias := range aliases {
|
||||
batch.Queue(
|
||||
`INSERT INTO ingredient_aliases (ingredient_id, lang, alias) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
id, lang, alias,
|
||||
)
|
||||
}
|
||||
results := r.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
for range aliases {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
return fmt.Errorf("upsert ingredient alias %s/%s: %w", id, lang, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertCategoryTranslation inserts or replaces a localized category name.
|
||||
func (r *Repository) UpsertCategoryTranslation(ctx context.Context, slug, lang, name string) error {
|
||||
query := `
|
||||
INSERT INTO ingredient_category_translations (category_slug, lang, name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (category_slug, lang) DO UPDATE SET name = EXCLUDED.name`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, slug, lang, name); err != nil {
|
||||
return fmt.Errorf("upsert category translation %s/%s: %w", slug, lang, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
// scanMappingWrite scans rows from Upsert / ListMissingTranslation queries
|
||||
// (no aliases lateral join, no category_name).
|
||||
func scanMappingWrite(row pgx.Row) (*IngredientMapping, error) {
|
||||
var m IngredientMapping
|
||||
err := row.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Aliases = json.RawMessage("[]")
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// scanMappingRead scans rows from GetByID / Search queries
|
||||
// (includes category_name and aliases lateral join).
|
||||
func scanMappingRead(row pgx.Row) (*IngredientMapping, error) {
|
||||
var m IngredientMapping
|
||||
var aliases []byte
|
||||
err := row.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Aliases = json.RawMessage(aliases)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func collectMappingsWrite(rows pgx.Rows) ([]*IngredientMapping, error) {
|
||||
var result []*IngredientMapping
|
||||
for rows.Next() {
|
||||
var m IngredientMapping
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan mapping: %w", err)
|
||||
}
|
||||
m.Aliases = json.RawMessage("[]")
|
||||
result = append(result, &m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func collectMappingsRead(rows pgx.Rows) ([]*IngredientMapping, error) {
|
||||
var result []*IngredientMapping
|
||||
for rows.Next() {
|
||||
var m IngredientMapping
|
||||
var aliases []byte
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan mapping: %w", err)
|
||||
}
|
||||
m.Aliases = json.RawMessage(aliases)
|
||||
result = append(result, &m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
28
backend/internal/domain/language/handler.go
Normal file
28
backend/internal/domain/language/handler.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
)
|
||||
|
||||
type languageItem struct {
|
||||
Code string `json:"code"`
|
||||
NativeName string `json:"native_name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// List handles GET /languages — returns the active language list loaded from DB.
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
items := make([]languageItem, 0, len(locale.Languages))
|
||||
for i, l := range locale.Languages {
|
||||
items = append(items, languageItem{
|
||||
Code: l.Code,
|
||||
NativeName: l.NativeName,
|
||||
SortOrder: i + 1,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"languages": items})
|
||||
}
|
||||
56
backend/internal/domain/menu/entity.go
Normal file
56
backend/internal/domain/menu/entity.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package menu
|
||||
|
||||
// MenuPlan is a weekly meal plan for a user.
|
||||
type MenuPlan struct {
|
||||
ID string `json:"id"`
|
||||
WeekStart string `json:"week_start"` // YYYY-MM-DD (Monday)
|
||||
Days []MenuDay `json:"days"`
|
||||
}
|
||||
|
||||
// MenuDay groups three meal slots for one calendar day.
|
||||
type MenuDay struct {
|
||||
Day int `json:"day"` // 1=Monday … 7=Sunday
|
||||
Date string `json:"date"`
|
||||
Meals []MealSlot `json:"meals"`
|
||||
TotalCalories float64 `json:"total_calories"`
|
||||
}
|
||||
|
||||
// MealSlot holds a single meal within a day.
|
||||
type MealSlot struct {
|
||||
ID string `json:"id"`
|
||||
MealType string `json:"meal_type"` // breakfast | lunch | dinner
|
||||
Recipe *MenuRecipe `json:"recipe,omitempty"`
|
||||
}
|
||||
|
||||
// MenuRecipe is a thin projection of a saved recipe used in the menu view.
|
||||
type MenuRecipe struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Nutrition NutritionInfo `json:"nutrition_per_serving"`
|
||||
}
|
||||
|
||||
// NutritionInfo holds macronutrient data.
|
||||
type NutritionInfo struct {
|
||||
Calories float64 `json:"calories"`
|
||||
ProteinG float64 `json:"protein_g"`
|
||||
FatG float64 `json:"fat_g"`
|
||||
CarbsG float64 `json:"carbs_g"`
|
||||
}
|
||||
|
||||
// PlanItem is the input needed to create one menu_items row.
|
||||
type PlanItem struct {
|
||||
DayOfWeek int
|
||||
MealType string
|
||||
RecipeID string
|
||||
}
|
||||
|
||||
// ShoppingItem is one entry in the shopping list.
|
||||
type ShoppingItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
Checked bool `json:"checked"`
|
||||
InStock float64 `json:"in_stock"`
|
||||
}
|
||||
584
backend/internal/domain/menu/handler.go
Normal file
584
backend/internal/domain/menu/handler.go
Normal file
@@ -0,0 +1,584 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
"github.com/food-ai/backend/internal/domain/dish"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/domain/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// PhotoSearcher searches for a photo by query string.
|
||||
type PhotoSearcher interface {
|
||||
SearchPhoto(ctx context.Context, query string) (string, error)
|
||||
}
|
||||
|
||||
// UserLoader loads a user profile by ID.
|
||||
type UserLoader interface {
|
||||
GetByID(ctx context.Context, id string) (*user.User, error)
|
||||
}
|
||||
|
||||
// ProductLister returns human-readable product lines for the AI prompt.
|
||||
type ProductLister interface {
|
||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
|
||||
type RecipeSaver interface {
|
||||
Create(ctx context.Context, req dish.CreateRequest) (string, error)
|
||||
}
|
||||
|
||||
// MenuGenerator generates a 7-day meal plan via an AI provider.
|
||||
type MenuGenerator interface {
|
||||
GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error)
|
||||
}
|
||||
|
||||
// Handler handles menu and shopping-list endpoints.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
menuGenerator MenuGenerator
|
||||
pexels PhotoSearcher
|
||||
userLoader UserLoader
|
||||
productLister ProductLister
|
||||
recipeSaver RecipeSaver
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(
|
||||
repo *Repository,
|
||||
menuGenerator MenuGenerator,
|
||||
pexels PhotoSearcher,
|
||||
userLoader UserLoader,
|
||||
productLister ProductLister,
|
||||
recipeSaver RecipeSaver,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
menuGenerator: menuGenerator,
|
||||
pexels: pexels,
|
||||
userLoader: userLoader,
|
||||
productLister: productLister,
|
||||
recipeSaver: recipeSaver,
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Menu endpoints
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// GetMenu handles GET /menu?week=YYYY-WNN
|
||||
func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
slog.Error("get menu", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load menu")
|
||||
return
|
||||
}
|
||||
if plan == nil {
|
||||
// No plan yet — return empty response.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"week_start": weekStart,
|
||||
"days": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// GenerateMenu handles POST /ai/generate-menu
|
||||
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Week string `json:"week"` // optional, defaults to current week
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
weekStart, err := resolveWeekStart(body.Week)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
// Load user profile.
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("load user for menu generation", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user profile")
|
||||
return
|
||||
}
|
||||
|
||||
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
|
||||
|
||||
// Attach pantry products.
|
||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||
menuReq.AvailableProducts = products
|
||||
}
|
||||
|
||||
// Generate 7-day plan via Gemini.
|
||||
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
|
||||
if err != nil {
|
||||
slog.Error("generate menu", "user_id", userID, "err", err)
|
||||
writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch Pexels images for all 21 recipes in parallel.
|
||||
type indexedRecipe struct {
|
||||
day int
|
||||
meal int
|
||||
imageURL string
|
||||
}
|
||||
imageResults := make([]indexedRecipe, 0, len(days)*3)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for di, day := range days {
|
||||
for mi := range day.Meals {
|
||||
wg.Add(1)
|
||||
go func(di, mi int, query string) {
|
||||
defer wg.Done()
|
||||
url, err := h.pexels.SearchPhoto(r.Context(), query)
|
||||
if err != nil {
|
||||
slog.Warn("pexels search failed", "query", query, "err", err)
|
||||
}
|
||||
mu.Lock()
|
||||
imageResults = append(imageResults, indexedRecipe{di, mi, url})
|
||||
mu.Unlock()
|
||||
}(di, mi, day.Meals[mi].Recipe.ImageQuery)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, res := range imageResults {
|
||||
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
|
||||
}
|
||||
|
||||
// Persist all 21 recipes as dish+recipe rows.
|
||||
type savedRef struct {
|
||||
day int
|
||||
meal int
|
||||
recipeID string
|
||||
}
|
||||
refs := make([]savedRef, 0, len(days)*3)
|
||||
for di, day := range days {
|
||||
for mi, meal := range day.Meals {
|
||||
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
|
||||
if err != nil {
|
||||
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save recipes")
|
||||
return
|
||||
}
|
||||
refs = append(refs, savedRef{di, mi, recipeID})
|
||||
}
|
||||
}
|
||||
|
||||
// Build PlanItems list in day/meal order.
|
||||
planItems := make([]PlanItem, 0, 21)
|
||||
for _, ref := range refs {
|
||||
planItems = append(planItems, PlanItem{
|
||||
DayOfWeek: days[ref.day].Day,
|
||||
MealType: days[ref.day].Meals[ref.meal].MealType,
|
||||
RecipeID: ref.recipeID,
|
||||
})
|
||||
}
|
||||
|
||||
// Persist in a single transaction.
|
||||
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
|
||||
if err != nil {
|
||||
slog.Error("save menu plan", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate shopping list.
|
||||
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
|
||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil {
|
||||
slog.Warn("auto-generate shopping list", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the freshly saved plan.
|
||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil || plan == nil {
|
||||
slog.Error("load generated menu", "err", err, "plan_nil", plan == nil)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load generated menu")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// UpdateMenuItem handles PUT /menu/items/{id}
|
||||
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
itemID := chi.URLParam(r, "id")
|
||||
|
||||
var body struct {
|
||||
RecipeID string `json:"recipe_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "recipe_id required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "menu item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("update menu item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update menu item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteMenuItem handles DELETE /menu/items/{id}
|
||||
func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
itemID := chi.URLParam(r, "id")
|
||||
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "menu item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete menu item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete menu item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Shopping list endpoints
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// GenerateShoppingList handles POST /shopping-list/generate
|
||||
func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Week string `json:"week"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
weekStart, err := resolveWeekStart(body.Week)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "no menu plan found for this week")
|
||||
return
|
||||
}
|
||||
slog.Error("get plan id", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.buildShoppingList(r.Context(), planID)
|
||||
if err != nil {
|
||||
slog.Error("build shopping list", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to build shopping list")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
|
||||
slog.Error("upsert shopping list", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to save shopping list")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GetShoppingList handles GET /shopping-list?week=YYYY-WNN
|
||||
func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
writeJSON(w, http.StatusOK, []ShoppingItem{})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shopping list")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []ShoppingItem{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// ToggleShoppingItem handles PATCH /shopping-list/items/{index}/check
|
||||
func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
indexStr := chi.URLParam(r, "index")
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid item index")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Checked bool `json:"checked"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid week parameter")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "menu plan not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
|
||||
slog.Error("toggle shopping item", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update item")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// buildShoppingList aggregates all ingredients from a plan's recipes.
|
||||
func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]ShoppingItem, error) {
|
||||
rows, err := h.repo.GetIngredientsByPlan(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type key struct{ name, unit string }
|
||||
totals := map[key]float64{}
|
||||
|
||||
for _, row := range rows {
|
||||
unit := ""
|
||||
if row.UnitCode != nil {
|
||||
unit = *row.UnitCode
|
||||
}
|
||||
k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit}
|
||||
totals[k] += row.Amount
|
||||
}
|
||||
|
||||
items := make([]ShoppingItem, 0, len(totals))
|
||||
for k, amount := range totals {
|
||||
items = append(items, ShoppingItem{
|
||||
Name: k.name,
|
||||
Category: "other",
|
||||
Amount: amount,
|
||||
Unit: k.unit,
|
||||
Checked: false,
|
||||
InStock: 0,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type userPreferences struct {
|
||||
Cuisines []string `json:"cuisines"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
}
|
||||
|
||||
func buildMenuRequest(u *user.User, lang string) ai.MenuRequest {
|
||||
req := ai.MenuRequest{DailyCalories: 2000, Lang: lang}
|
||||
if u.Goal != nil {
|
||||
req.UserGoal = *u.Goal
|
||||
}
|
||||
if u.DailyCalories != nil && *u.DailyCalories > 0 {
|
||||
req.DailyCalories = *u.DailyCalories
|
||||
}
|
||||
if len(u.Preferences) > 0 {
|
||||
var prefs userPreferences
|
||||
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
|
||||
req.CuisinePrefs = prefs.Cuisines
|
||||
req.Restrictions = prefs.Restrictions
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest.
|
||||
func recipeToCreateRequest(r ai.Recipe) dish.CreateRequest {
|
||||
cr := dish.CreateRequest{
|
||||
Name: r.Title,
|
||||
Description: r.Description,
|
||||
CuisineSlug: mapCuisineSlug(r.Cuisine),
|
||||
ImageURL: r.ImageURL,
|
||||
Difficulty: r.Difficulty,
|
||||
PrepTimeMin: r.PrepTimeMin,
|
||||
CookTimeMin: r.CookTimeMin,
|
||||
Servings: r.Servings,
|
||||
Calories: r.Nutrition.Calories,
|
||||
Protein: r.Nutrition.ProteinG,
|
||||
Fat: r.Nutrition.FatG,
|
||||
Carbs: r.Nutrition.CarbsG,
|
||||
Source: "menu",
|
||||
}
|
||||
for _, ing := range r.Ingredients {
|
||||
cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{
|
||||
Name: ing.Name,
|
||||
Amount: ing.Amount,
|
||||
Unit: ing.Unit,
|
||||
})
|
||||
}
|
||||
for _, s := range r.Steps {
|
||||
cr.Steps = append(cr.Steps, dish.StepInput{
|
||||
Number: s.Number,
|
||||
Description: s.Description,
|
||||
TimerSeconds: s.TimerSeconds,
|
||||
})
|
||||
}
|
||||
cr.Tags = append(cr.Tags, r.Tags...)
|
||||
return cr
|
||||
}
|
||||
|
||||
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||
// Falls back to "other".
|
||||
func mapCuisineSlug(cuisine string) string {
|
||||
known := map[string]string{
|
||||
"russian": "russian",
|
||||
"italian": "italian",
|
||||
"french": "french",
|
||||
"chinese": "chinese",
|
||||
"japanese": "japanese",
|
||||
"korean": "korean",
|
||||
"mexican": "mexican",
|
||||
"mediterranean": "mediterranean",
|
||||
"indian": "indian",
|
||||
"thai": "thai",
|
||||
"american": "american",
|
||||
"georgian": "georgian",
|
||||
"spanish": "spanish",
|
||||
"german": "german",
|
||||
"middle_eastern": "middle_eastern",
|
||||
"turkish": "turkish",
|
||||
"greek": "greek",
|
||||
"vietnamese": "vietnamese",
|
||||
"asian": "other",
|
||||
"european": "other",
|
||||
}
|
||||
if s, ok := known[cuisine]; ok {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
|
||||
func resolveWeekStart(week string) (string, error) {
|
||||
if week == "" {
|
||||
return currentWeekStart(), nil
|
||||
}
|
||||
var year, w int
|
||||
if _, err := fmt.Sscanf(week, "%d-W%d", &year, &w); err != nil || w < 1 || w > 53 {
|
||||
return "", fmt.Errorf("invalid week: %q", week)
|
||||
}
|
||||
t := mondayOfISOWeek(year, w)
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
func currentWeekStart() string {
|
||||
now := time.Now().UTC()
|
||||
year, week := now.ISOWeek()
|
||||
return mondayOfISOWeek(year, week).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func mondayOfISOWeek(year, week int) time.Time {
|
||||
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC)
|
||||
weekday := int(jan4.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
monday1 := jan4.AddDate(0, 0, 1-weekday)
|
||||
return monday1.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
325
backend/internal/domain/menu/repository.go
Normal file
325
backend/internal/domain/menu/repository.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a menu item is not found for the user.
|
||||
var ErrNotFound = errors.New("menu item not found")
|
||||
|
||||
// Repository handles persistence for menu plans, items, and shopping lists.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
||||
// Returns nil, nil when no plan exists for that week.
|
||||
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT mp.id, mp.week_start::text,
|
||||
mi.id, mi.day_of_week, mi.meal_type,
|
||||
rec.id,
|
||||
COALESCE(dt.name, d.name),
|
||||
COALESCE(d.image_url, ''),
|
||||
rec.calories_per_serving,
|
||||
rec.protein_per_serving,
|
||||
rec.fat_per_serving,
|
||||
rec.carbs_per_serving
|
||||
FROM menu_plans mp
|
||||
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||||
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
LEFT JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
||||
ORDER BY mi.day_of_week,
|
||||
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get menu by week: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plan *MenuPlan
|
||||
dayMap := map[int]*MenuDay{}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
planID, planWeekStart string
|
||||
itemID, mealType *string
|
||||
dow *int
|
||||
recipeID, title, imageURL *string
|
||||
calPer, protPer, fatPer, carbPer *float64
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&planID, &planWeekStart,
|
||||
&itemID, &dow, &mealType,
|
||||
&recipeID, &title, &imageURL,
|
||||
&calPer, &protPer, &fatPer, &carbPer,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
plan = &MenuPlan{ID: planID, WeekStart: planWeekStart}
|
||||
}
|
||||
|
||||
if itemID == nil || dow == nil || mealType == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
day, ok := dayMap[*dow]
|
||||
if !ok {
|
||||
day = &MenuDay{Day: *dow, Date: dayDate(planWeekStart, *dow)}
|
||||
dayMap[*dow] = day
|
||||
}
|
||||
|
||||
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
||||
if recipeID != nil && title != nil {
|
||||
nutrition := NutritionInfo{
|
||||
Calories: derefFloat(calPer),
|
||||
ProteinG: derefFloat(protPer),
|
||||
FatG: derefFloat(fatPer),
|
||||
CarbsG: derefFloat(carbPer),
|
||||
}
|
||||
slot.Recipe = &MenuRecipe{
|
||||
ID: *recipeID,
|
||||
Title: *title,
|
||||
ImageURL: derefStr(imageURL),
|
||||
Nutrition: nutrition,
|
||||
}
|
||||
day.TotalCalories += nutrition.Calories
|
||||
}
|
||||
day.Meals = append(day.Meals, slot)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Assemble days in order.
|
||||
for dow := 1; dow <= 7; dow++ {
|
||||
if d, ok := dayMap[dow]; ok {
|
||||
plan.Days = append(plan.Days, *d)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// SaveMenuInTx upserts a menu_plan row, wipes previous menu_items, and inserts
|
||||
// the new ones — all in a single transaction.
|
||||
func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var planID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO menu_plans (user_id, week_start)
|
||||
VALUES ($1, $2::date)
|
||||
ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now()
|
||||
RETURNING id`, userID, weekStart).Scan(&planID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upsert menu_plan: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE menu_plan_id = $1`, planID); err != nil {
|
||||
return "", fmt.Errorf("delete old menu items: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if _, err = tx.Exec(ctx, `
|
||||
INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
planID, item.DayOfWeek, item.MealType, item.RecipeID,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("insert menu item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
// UpdateItem replaces the recipe in a menu slot.
|
||||
func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE menu_items mi
|
||||
SET recipe_id = $3
|
||||
FROM menu_plans mp
|
||||
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
||||
itemID, userID, recipeID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update menu item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteItem removes a menu slot.
|
||||
func (r *Repository) DeleteItem(ctx context.Context, itemID, userID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM menu_items mi
|
||||
USING menu_plans mp
|
||||
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
||||
itemID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete menu item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertShoppingList stores the shopping list for a menu plan.
|
||||
func (r *Repository) UpsertShoppingList(ctx context.Context, userID, planID string, items []ShoppingItem) error {
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal shopping items: %w", err)
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO shopping_lists (user_id, menu_plan_id, items)
|
||||
VALUES ($1, $2, $3::jsonb)
|
||||
ON CONFLICT (user_id, menu_plan_id) DO UPDATE
|
||||
SET items = EXCLUDED.items, generated_at = now()`,
|
||||
userID, planID, string(raw),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetShoppingList returns the shopping list for the user's plan.
|
||||
func (r *Repository) GetShoppingList(ctx context.Context, userID, planID string) ([]ShoppingItem, error) {
|
||||
var raw []byte
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT items FROM shopping_lists
|
||||
WHERE user_id = $1 AND menu_plan_id = $2`,
|
||||
userID, planID,
|
||||
).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get shopping list: %w", err)
|
||||
}
|
||||
var items []ShoppingItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal shopping items: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ToggleShoppingItem flips the checked flag for the item at the given index.
|
||||
func (r *Repository) ToggleShoppingItem(ctx context.Context, userID, planID string, index int, checked bool) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE shopping_lists
|
||||
SET items = jsonb_set(items, ARRAY[$1::text, 'checked'], to_jsonb($2::boolean))
|
||||
WHERE user_id = $3 AND menu_plan_id = $4`,
|
||||
fmt.Sprintf("%d", index), checked, userID, planID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("toggle shopping item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlanIDByWeek returns the menu_plan id for the user and given Monday date.
|
||||
func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart string) (string, error) {
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id FROM menu_plans WHERE user_id = $1 AND week_start::text = $2`,
|
||||
userID, weekStart,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get plan id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetIngredientsByPlan returns all ingredients from all recipes in the plan.
|
||||
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type
|
||||
FROM menu_items mi
|
||||
JOIN recipes rec ON rec.id = mi.recipe_id
|
||||
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id
|
||||
WHERE mi.menu_plan_id = $1
|
||||
ORDER BY ri.sort_order`, planID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ingredientRow
|
||||
for rows.Next() {
|
||||
var row ingredientRow
|
||||
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type ingredientRow struct {
|
||||
Name string
|
||||
Amount float64
|
||||
UnitCode *string
|
||||
MealType string
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func dayDate(weekStart string, dow int) string {
|
||||
t, err := time.Parse("2006-01-02", weekStart)
|
||||
if err != nil {
|
||||
return weekStart
|
||||
}
|
||||
return t.AddDate(0, 0, dow-1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func derefFloat(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
41
backend/internal/domain/product/entity.go
Normal file
41
backend/internal/domain/product/entity.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package product
|
||||
|
||||
import "time"
|
||||
|
||||
// Product is a user's food item in their pantry.
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
ExpiringSoon bool `json:"expiring_soon"`
|
||||
}
|
||||
|
||||
// CreateRequest is the body for POST /products.
|
||||
type CreateRequest struct {
|
||||
PrimaryIngredientID *string `json:"primary_ingredient_id"`
|
||||
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields.
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the body for PUT /products/{id}.
|
||||
// All fields are optional (nil = keep existing value).
|
||||
type UpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
Unit *string `json:"unit"`
|
||||
Category *string `json:"category"`
|
||||
StorageDays *int `json:"storage_days"`
|
||||
}
|
||||
137
backend/internal/domain/product/handler.go
Normal file
137
backend/internal/domain/product/handler.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package product
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles /products HTTP requests.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// List handles GET /products.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
products, err := h.repo.List(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("list products", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list products")
|
||||
return
|
||||
}
|
||||
if products == nil {
|
||||
products = []*Product{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, products)
|
||||
}
|
||||
|
||||
// Create handles POST /products.
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
var req CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.repo.Create(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("create product", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to create product")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// BatchCreate handles POST /products/batch.
|
||||
func (h *Handler) BatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
var items []CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
writeJSON(w, http.StatusCreated, []*Product{})
|
||||
return
|
||||
}
|
||||
|
||||
products, err := h.repo.BatchCreate(r.Context(), userID, items)
|
||||
if err != nil {
|
||||
slog.Error("batch create products", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to create products")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, products)
|
||||
}
|
||||
|
||||
// Update handles PUT /products/{id}.
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req UpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.repo.Update(r.Context(), id, userID, req)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeErrorJSON(w, http.StatusNotFound, "product not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("update product", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to update product")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /products/{id}.
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeErrorJSON(w, http.StatusNotFound, "product not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete product", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete product")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
202
backend/internal/domain/product/repository.go
Normal file
202
backend/internal/domain/product/repository.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package product
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a product is not found or does not belong to the user.
|
||||
var ErrNotFound = errors.New("product not found")
|
||||
|
||||
// Repository handles product persistence.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
|
||||
// which prevents it from being used as a stored generated column.
|
||||
const selectCols = `id, user_id, primary_ingredient_id, name, quantity, unit, category, storage_days, added_at,
|
||||
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
|
||||
|
||||
// List returns all products for a user, sorted by expires_at ASC.
|
||||
func (r *Repository) List(ctx context.Context, userID string) ([]*Product, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+selectCols+`
|
||||
FROM products
|
||||
WHERE user_id = $1
|
||||
ORDER BY expires_at ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list products: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectProducts(rows)
|
||||
}
|
||||
|
||||
// Create inserts a new product and returns the created record.
|
||||
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) {
|
||||
storageDays := req.StorageDays
|
||||
if storageDays <= 0 {
|
||||
storageDays = 7
|
||||
}
|
||||
unit := req.Unit
|
||||
if unit == "" {
|
||||
unit = "pcs"
|
||||
}
|
||||
qty := req.Quantity
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
|
||||
// Accept both new and legacy field names.
|
||||
primaryID := req.PrimaryIngredientID
|
||||
if primaryID == nil {
|
||||
primaryID = req.MappingID
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO products (user_id, primary_ingredient_id, name, quantity, unit, category, storage_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING `+selectCols,
|
||||
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
|
||||
)
|
||||
return scanProduct(row)
|
||||
}
|
||||
|
||||
// BatchCreate inserts multiple products sequentially and returns all created records.
|
||||
func (r *Repository) BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) {
|
||||
var result []*Product
|
||||
for _, req := range items {
|
||||
p, err := r.Create(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch create product %q: %w", req.Name, err)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Update modifies an existing product. Only non-nil fields are changed.
|
||||
// Returns ErrNotFound if the product does not exist or belongs to a different user.
|
||||
func (r *Repository) Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error) {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
UPDATE products SET
|
||||
name = COALESCE($3, name),
|
||||
quantity = COALESCE($4, quantity),
|
||||
unit = COALESCE($5, unit),
|
||||
category = COALESCE($6, category),
|
||||
storage_days = COALESCE($7, storage_days)
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING `+selectCols,
|
||||
id, userID, req.Name, req.Quantity, req.Unit, req.Category, req.StorageDays,
|
||||
)
|
||||
p, err := scanProduct(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Delete removes a product. Returns ErrNotFound if it does not exist or belongs to a different user.
|
||||
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM products WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete product: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForPrompt returns a human-readable list of user's products for the AI prompt.
|
||||
// Expiring soon items are marked with ⚠.
|
||||
func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH p AS (
|
||||
SELECT name, quantity, unit,
|
||||
(added_at + storage_days * INTERVAL '1 day') AS expires_at
|
||||
FROM products
|
||||
WHERE user_id = $1
|
||||
)
|
||||
SELECT name, quantity, unit, expires_at
|
||||
FROM p
|
||||
ORDER BY expires_at ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list products for prompt: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lines []string
|
||||
now := time.Now()
|
||||
for rows.Next() {
|
||||
var name, unit string
|
||||
var qty float64
|
||||
var expiresAt time.Time
|
||||
if err := rows.Scan(&name, &qty, &unit, &expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("scan product for prompt: %w", err)
|
||||
}
|
||||
daysLeft := int(expiresAt.Sub(now).Hours() / 24)
|
||||
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
|
||||
switch {
|
||||
case daysLeft <= 0:
|
||||
line += " (expires today ⚠)"
|
||||
case daysLeft == 1:
|
||||
line += " (expires tomorrow ⚠)"
|
||||
case daysLeft <= 3:
|
||||
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines, rows.Err()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanProduct(row pgx.Row) (*Product, error) {
|
||||
var p Product
|
||||
err := row.Scan(
|
||||
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
computeDaysLeft(&p)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func collectProducts(rows pgx.Rows) ([]*Product, error) {
|
||||
var result []*Product
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit,
|
||||
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan product: %w", err)
|
||||
}
|
||||
computeDaysLeft(&p)
|
||||
result = append(result, &p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func computeDaysLeft(p *Product) {
|
||||
d := int(time.Until(p.ExpiresAt).Hours() / 24)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
p.DaysLeft = d
|
||||
p.ExpiringSoon = d <= 3
|
||||
}
|
||||
49
backend/internal/domain/recipe/entity.go
Normal file
49
backend/internal/domain/recipe/entity.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package recipe
|
||||
|
||||
import "time"
|
||||
|
||||
// Recipe is a cooking variant of a Dish in the catalog.
|
||||
// It links to a Dish for all presentational data (name, image, cuisine, tags).
|
||||
type Recipe struct {
|
||||
ID string `json:"id"`
|
||||
DishID string `json:"dish_id"`
|
||||
Source string `json:"source"` // ai | user | spoonacular
|
||||
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
FatPerServing *float64 `json:"fat_per_serving"`
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
FiberPerServing *float64 `json:"fiber_per_serving"`
|
||||
|
||||
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||
Steps []RecipeStep `json:"steps"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient row from recipe_ingredients.
|
||||
type RecipeIngredient struct {
|
||||
ID string `json:"id"`
|
||||
IngredientID *string `json:"ingredient_id"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
UnitCode *string `json:"unit_code"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step row from recipe_steps.
|
||||
type RecipeStep struct {
|
||||
ID string `json:"id"`
|
||||
StepNumber int `json:"step_number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
}
|
||||
58
backend/internal/domain/recipe/handler.go
Normal file
58
backend/internal/domain/recipe/handler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for recipes.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// GetByID handles GET /recipes/{id}.
|
||||
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
rec, err := h.repo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("get recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get recipe")
|
||||
return
|
||||
}
|
||||
if rec == nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
142
backend/internal/domain/recipe/repository.go
Normal file
142
backend/internal/domain/recipe/repository.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for recipes and their relational sub-tables.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// GetByID returns a recipe with its ingredients and steps.
|
||||
// Text is resolved for the language stored in ctx (English fallback).
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
|
||||
const q = `
|
||||
SELECT r.id, r.dish_id, r.source, r.difficulty,
|
||||
r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving,
|
||||
r.carbs_per_serving, r.fiber_per_serving,
|
||||
rt.notes,
|
||||
r.created_at, r.updated_at
|
||||
FROM recipes r
|
||||
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
|
||||
WHERE r.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, id, lang)
|
||||
rec, err := scanRecipe(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get recipe %s: %w", id, err)
|
||||
}
|
||||
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Count returns the total number of recipes.
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM recipes`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count recipes: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// loadIngredients fills rec.Ingredients from recipe_ingredients.
|
||||
func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT ri.id, ri.ingredient_id,
|
||||
COALESCE(rit.name, ri.name) AS name,
|
||||
ri.amount, ri.unit_code, ri.is_optional, ri.sort_order
|
||||
FROM recipe_ingredients ri
|
||||
LEFT JOIN recipe_ingredient_translations rit
|
||||
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||
WHERE ri.recipe_id = $1
|
||||
ORDER BY ri.sort_order`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var ing RecipeIngredient
|
||||
if err := rows.Scan(
|
||||
&ing.ID, &ing.IngredientID, &ing.Name,
|
||||
&ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan ingredient: %w", err)
|
||||
}
|
||||
rec.Ingredients = append(rec.Ingredients, ing)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadSteps fills rec.Steps from recipe_steps.
|
||||
func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rs.id, rs.step_number,
|
||||
COALESCE(rst.description, rs.description) AS description,
|
||||
rs.timer_seconds, rs.image_url
|
||||
FROM recipe_steps rs
|
||||
LEFT JOIN recipe_step_translations rst
|
||||
ON rst.step_id = rs.id AND rst.lang = $2
|
||||
WHERE rs.recipe_id = $1
|
||||
ORDER BY rs.step_number`, rec.ID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var s RecipeStep
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL,
|
||||
); err != nil {
|
||||
return fmt.Errorf("scan step: %w", err)
|
||||
}
|
||||
rec.Steps = append(rec.Steps, s)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanRecipe(row pgx.Row) (*Recipe, error) {
|
||||
var rec Recipe
|
||||
err := row.Scan(
|
||||
&rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty,
|
||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing,
|
||||
&rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&rec.Notes,
|
||||
&rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = []RecipeIngredient{}
|
||||
rec.Steps = []RecipeStep{}
|
||||
return &rec, nil
|
||||
}
|
||||
324
backend/internal/domain/recognition/handler.go
Normal file
324
backend/internal/domain/recognition/handler.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package recognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/domain/ingredient"
|
||||
)
|
||||
|
||||
// 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
|
||||
UpsertAliases(ctx context.Context, id, lang string, aliases []string) error
|
||||
}
|
||||
|
||||
// Recognizer is the AI provider interface for image-based food recognition.
|
||||
type Recognizer interface {
|
||||
RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.ReceiptResult, error)
|
||||
RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]ai.RecognizedItem, error)
|
||||
RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*ai.DishResult, error)
|
||||
ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error)
|
||||
}
|
||||
|
||||
// Handler handles POST /ai/* recognition endpoints.
|
||||
type Handler struct {
|
||||
recognizer Recognizer
|
||||
ingredientRepo IngredientRepository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler {
|
||||
return &Handler{recognizer: recognizer, ingredientRepo: repo}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// imageRequest is the common request body containing a single base64-encoded image.
|
||||
type imageRequest struct {
|
||||
ImageBase64 string `json:"image_base64"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// imagesRequest is the request body for multi-image endpoints.
|
||||
type imagesRequest struct {
|
||||
Images []imageRequest `json:"images"`
|
||||
}
|
||||
|
||||
// EnrichedItem is a recognized food item enriched with ingredient_mappings data.
|
||||
type EnrichedItem struct {
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MappingID *string `json:"mapping_id"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
}
|
||||
|
||||
// ReceiptResponse is the response for POST /ai/recognize-receipt.
|
||||
type ReceiptResponse struct {
|
||||
Items []EnrichedItem `json:"items"`
|
||||
Unrecognized []ai.UnrecognizedItem `json:"unrecognized"`
|
||||
}
|
||||
|
||||
// DishResponse is the response for POST /ai/recognize-dish.
|
||||
type DishResponse = ai.DishResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RecognizeReceipt handles POST /ai/recognize-receipt.
|
||||
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||
func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
_ = userID // logged for tracing
|
||||
|
||||
var req imageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.recognizer.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType)
|
||||
if err != nil {
|
||||
slog.Error("recognize receipt", "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
enriched := h.enrichItems(r.Context(), result.Items)
|
||||
writeJSON(w, http.StatusOK, ReceiptResponse{
|
||||
Items: enriched,
|
||||
Unrecognized: result.Unrecognized,
|
||||
})
|
||||
}
|
||||
|
||||
// RecognizeProducts handles POST /ai/recognize-products.
|
||||
// Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]}
|
||||
func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) {
|
||||
var req imagesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Images) == 0 {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "at least one image is required")
|
||||
return
|
||||
}
|
||||
if len(req.Images) > 3 {
|
||||
req.Images = req.Images[:3] // cap at 3 photos as per spec
|
||||
}
|
||||
|
||||
// Process each image in parallel.
|
||||
allItems := make([][]ai.RecognizedItem, len(req.Images))
|
||||
var wg sync.WaitGroup
|
||||
for i, img := range req.Images {
|
||||
wg.Add(1)
|
||||
go func(i int, img imageRequest) {
|
||||
defer wg.Done()
|
||||
items, err := h.recognizer.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType)
|
||||
if err != nil {
|
||||
slog.Warn("recognize products from image", "index", i, "err", err)
|
||||
return
|
||||
}
|
||||
allItems[i] = items
|
||||
}(i, img)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
merged := mergeAndDeduplicate(allItems)
|
||||
enriched := h.enrichItems(r.Context(), merged)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": enriched})
|
||||
}
|
||||
|
||||
// RecognizeDish handles POST /ai/recognize-dish.
|
||||
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||
func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
||||
var req imageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.recognizer.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType)
|
||||
if err != nil {
|
||||
slog.Error("recognize dish", "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// enrichItems matches each recognized item against ingredient_mappings.
|
||||
// Items without a match trigger a Gemini classification call and upsert into the DB.
|
||||
func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem {
|
||||
result := make([]EnrichedItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
enriched := EnrichedItem{
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
Unit: item.Unit,
|
||||
Category: item.Category,
|
||||
Confidence: item.Confidence,
|
||||
StorageDays: 7, // sensible default
|
||||
}
|
||||
|
||||
mapping, err := h.ingredientRepo.FuzzyMatch(ctx, item.Name)
|
||||
if err != nil {
|
||||
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", err)
|
||||
}
|
||||
|
||||
if mapping != nil {
|
||||
// Found existing mapping — use its canonical data.
|
||||
id := mapping.ID
|
||||
enriched.MappingID = &id
|
||||
if mapping.DefaultUnit != nil {
|
||||
enriched.Unit = *mapping.DefaultUnit
|
||||
}
|
||||
if mapping.StorageDays != nil {
|
||||
enriched.StorageDays = *mapping.StorageDays
|
||||
}
|
||||
if mapping.Category != nil {
|
||||
enriched.Category = *mapping.Category
|
||||
}
|
||||
} else {
|
||||
// No mapping — ask AI to classify and save for future reuse.
|
||||
classification, err := h.recognizer.ClassifyIngredient(ctx, item.Name)
|
||||
if err != nil {
|
||||
slog.Warn("classify unknown ingredient", "name", item.Name, "err", err)
|
||||
} else {
|
||||
saved := h.saveClassification(ctx, classification)
|
||||
if saved != nil {
|
||||
id := saved.ID
|
||||
enriched.MappingID = &id
|
||||
}
|
||||
enriched.Category = classification.Category
|
||||
enriched.Unit = classification.DefaultUnit
|
||||
enriched.StorageDays = classification.StorageDays
|
||||
}
|
||||
}
|
||||
result = append(result, enriched)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// saveClassification upserts an AI-produced ingredient classification into the DB.
|
||||
func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassification) *ingredient.IngredientMapping {
|
||||
if c == nil || c.CanonicalName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := &ingredient.IngredientMapping{
|
||||
CanonicalName: c.CanonicalName,
|
||||
Category: strPtr(c.Category),
|
||||
DefaultUnit: strPtr(c.DefaultUnit),
|
||||
CaloriesPer100g: c.CaloriesPer100g,
|
||||
ProteinPer100g: c.ProteinPer100g,
|
||||
FatPer100g: c.FatPer100g,
|
||||
CarbsPer100g: c.CarbsPer100g,
|
||||
StorageDays: intPtr(c.StorageDays),
|
||||
}
|
||||
|
||||
saved, err := h.ingredientRepo.Upsert(ctx, m)
|
||||
if err != nil {
|
||||
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(c.Aliases) > 0 {
|
||||
if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, "en", c.Aliases); err != nil {
|
||||
slog.Warn("upsert ingredient aliases", "id", saved.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range c.Translations {
|
||||
if err := h.ingredientRepo.UpsertTranslation(ctx, saved.ID, t.Lang, t.Name); err != nil {
|
||||
slog.Warn("upsert ingredient translation", "id", saved.ID, "lang", t.Lang, "err", err)
|
||||
}
|
||||
if len(t.Aliases) > 0 {
|
||||
if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, t.Lang, t.Aliases); err != nil {
|
||||
slog.Warn("upsert ingredient translation aliases", "id", saved.ID, "lang", t.Lang, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return saved
|
||||
}
|
||||
|
||||
// mergeAndDeduplicate combines results from multiple images.
|
||||
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||
func mergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
|
||||
seen := make(map[string]*ai.RecognizedItem)
|
||||
var order []string
|
||||
|
||||
for _, batch := range batches {
|
||||
for i := range batch {
|
||||
item := &batch[i]
|
||||
key := normalizeName(item.Name)
|
||||
if existing, ok := seen[key]; ok {
|
||||
existing.Quantity += item.Quantity
|
||||
// Keep the higher confidence estimate.
|
||||
if item.Confidence > existing.Confidence {
|
||||
existing.Confidence = item.Confidence
|
||||
}
|
||||
} else {
|
||||
seen[key] = item
|
||||
order = append(order, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ai.RecognizedItem, 0, len(order))
|
||||
for _, key := range order {
|
||||
result = append(result, *seen[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeName(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func intPtr(n int) *int {
|
||||
return &n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
159
backend/internal/domain/recommendation/handler.go
Normal file
159
backend/internal/domain/recommendation/handler.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/domain/user"
|
||||
)
|
||||
|
||||
// PhotoSearcher can search for a photo by text query.
|
||||
type PhotoSearcher interface {
|
||||
SearchPhoto(ctx context.Context, query string) (string, error)
|
||||
}
|
||||
|
||||
// UserLoader can load a user profile by ID.
|
||||
type UserLoader interface {
|
||||
GetByID(ctx context.Context, id string) (*user.User, error)
|
||||
}
|
||||
|
||||
// ProductLister returns a human-readable list of user's products for the AI prompt.
|
||||
type ProductLister interface {
|
||||
ListForPrompt(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
// userPreferences is the shape of user.Preferences JSONB.
|
||||
type userPreferences struct {
|
||||
Cuisines []string `json:"cuisines"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
}
|
||||
|
||||
// RecipeGenerator generates recipe recommendations via an AI provider.
|
||||
type RecipeGenerator interface {
|
||||
GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error)
|
||||
}
|
||||
|
||||
// Handler handles GET /recommendations.
|
||||
type Handler struct {
|
||||
recipeGenerator RecipeGenerator
|
||||
pexels PhotoSearcher
|
||||
userLoader UserLoader
|
||||
productLister ProductLister
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler {
|
||||
return &Handler{
|
||||
recipeGenerator: recipeGenerator,
|
||||
pexels: pexels,
|
||||
userLoader: userLoader,
|
||||
productLister: productLister,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendations handles GET /recommendations?count=5.
|
||||
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
count := 5
|
||||
if s := r.URL.Query().Get("count"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 20 {
|
||||
count = n
|
||||
}
|
||||
}
|
||||
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("load user for recommendations", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
|
||||
return
|
||||
}
|
||||
|
||||
req := buildRecipeRequest(u, count, locale.FromContext(r.Context()))
|
||||
|
||||
// Attach available products to personalise the prompt.
|
||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||
req.AvailableProducts = products
|
||||
} else {
|
||||
slog.Warn("load products for recommendations", "user_id", userID, "err", err)
|
||||
}
|
||||
|
||||
recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req)
|
||||
if err != nil {
|
||||
slog.Error("generate recipes", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch Pexels photos in parallel — each goroutine owns a distinct index.
|
||||
var wg sync.WaitGroup
|
||||
for i := range recipes {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
|
||||
if err != nil {
|
||||
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
|
||||
}
|
||||
recipes[i].ImageURL = imageURL
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, http.StatusOK, recipes)
|
||||
}
|
||||
|
||||
func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest {
|
||||
req := ai.RecipeRequest{
|
||||
Count: count,
|
||||
DailyCalories: 2000, // sensible default
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if u.Goal != nil {
|
||||
req.UserGoal = *u.Goal
|
||||
}
|
||||
if u.DailyCalories != nil && *u.DailyCalories > 0 {
|
||||
req.DailyCalories = *u.DailyCalories
|
||||
}
|
||||
|
||||
if len(u.Preferences) > 0 {
|
||||
var prefs userPreferences
|
||||
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
|
||||
req.CuisinePrefs = prefs.Cuisines
|
||||
req.Restrictions = prefs.Restrictions
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
76
backend/internal/domain/savedrecipe/entity.go
Normal file
76
backend/internal/domain/savedrecipe/entity.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package savedrecipe
|
||||
|
||||
import "time"
|
||||
|
||||
// UserSavedRecipe is a user's bookmark referencing a catalog recipe.
|
||||
// Display fields are populated by joining dishes + recipes.
|
||||
type UserSavedRecipe struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
|
||||
// Display data — joined from dishes + recipes.
|
||||
DishName string `json:"title"` // dish name used as display title
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
CuisineSlug *string `json:"cuisine_slug"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
FatPerServing *float64 `json:"fat_per_serving"`
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
|
||||
Ingredients []RecipeIngredient `json:"ingredients"`
|
||||
Steps []RecipeStep `json:"steps"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient row.
|
||||
type RecipeIngredient struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
UnitCode *string `json:"unit_code"`
|
||||
IsOptional bool `json:"is_optional"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step row.
|
||||
type RecipeStep struct {
|
||||
StepNumber int `json:"number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
}
|
||||
|
||||
// SaveRequest is the body for POST /saved-recipes.
|
||||
// When recipe_id is provided, the existing catalog recipe is bookmarked.
|
||||
// Otherwise a new dish+recipe is created from the supplied fields.
|
||||
type SaveRequest struct {
|
||||
RecipeID string `json:"recipe_id"` // optional: bookmark existing recipe
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
ImageURL string `json:"image_url"`
|
||||
// Ingredients / Steps / Tags / Nutrition are JSONB for backward compatibility
|
||||
// with the recommendation flow that sends the full Gemini response.
|
||||
Ingredients interface{} `json:"ingredients"`
|
||||
Steps interface{} `json:"steps"`
|
||||
Tags interface{} `json:"tags"`
|
||||
Nutrition interface{} `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||
var ErrNotFound = errorString("saved recipe not found")
|
||||
|
||||
type errorString string
|
||||
|
||||
func (e errorString) Error() string { return string(e) }
|
||||
134
backend/internal/domain/savedrecipe/handler.go
Normal file
134
backend/internal/domain/savedrecipe/handler.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const maxBodySize = 1 << 20 // 1 MB
|
||||
|
||||
// Handler handles HTTP requests for saved recipes.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// Save handles POST /saved-recipes.
|
||||
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
var req SaveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" && req.RecipeID == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "title or recipe_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := h.repo.Save(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("save recipe", "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec)
|
||||
}
|
||||
|
||||
// List handles GET /saved-recipes.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
recipes, err := h.repo.List(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("list saved recipes", "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
||||
return
|
||||
}
|
||||
if recipes == nil {
|
||||
recipes = []*UserSavedRecipe{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, recipes)
|
||||
}
|
||||
|
||||
// GetByID handles GET /saved-recipes/{id}.
|
||||
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
rec, err := h.repo.GetByID(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
slog.Error("get saved recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe")
|
||||
return
|
||||
}
|
||||
if rec == nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /saved-recipes/{id}.
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete saved recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
422
backend/internal/domain/savedrecipe/repository.go
Normal file
422
backend/internal/domain/savedrecipe/repository.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/domain/dish"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for user_saved_recipes.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
dishRepo *dish.Repository
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository {
|
||||
return &Repository{pool: pool, dishRepo: dishRepo}
|
||||
}
|
||||
|
||||
// Save bookmarks a recipe for the user.
|
||||
// If req.RecipeID is set, that existing catalog recipe is bookmarked.
|
||||
// Otherwise a new dish + recipe is created from the supplied fields.
|
||||
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) {
|
||||
recipeID := req.RecipeID
|
||||
|
||||
if recipeID == "" {
|
||||
// Build a dish.CreateRequest from the save body.
|
||||
cr := dish.CreateRequest{
|
||||
Name: req.Title,
|
||||
Description: req.Description,
|
||||
CuisineSlug: mapCuisineSlug(req.Cuisine),
|
||||
ImageURL: req.ImageURL,
|
||||
Source: req.Source,
|
||||
Difficulty: req.Difficulty,
|
||||
PrepTimeMin: req.PrepTimeMin,
|
||||
CookTimeMin: req.CookTimeMin,
|
||||
Servings: req.Servings,
|
||||
}
|
||||
|
||||
// Unmarshal ingredients.
|
||||
if req.Ingredients != nil {
|
||||
switch v := req.Ingredients.(type) {
|
||||
case []interface{}:
|
||||
for i, item := range v {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ing := dish.IngredientInput{
|
||||
Name: strVal(m["name"]),
|
||||
Amount: floatVal(m["amount"]),
|
||||
Unit: strVal(m["unit"]),
|
||||
}
|
||||
cr.Ingredients = append(cr.Ingredients, ing)
|
||||
_ = i
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []dish.IngredientInput
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Ingredients = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal steps.
|
||||
if req.Steps != nil {
|
||||
switch v := req.Steps.(type) {
|
||||
case []interface{}:
|
||||
for i, item := range v {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
num := int(floatVal(m["number"]))
|
||||
if num <= 0 {
|
||||
num = i + 1
|
||||
}
|
||||
step := dish.StepInput{
|
||||
Number: num,
|
||||
Description: strVal(m["description"]),
|
||||
}
|
||||
cr.Steps = append(cr.Steps, step)
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []dish.StepInput
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Steps = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal tags.
|
||||
if req.Tags != nil {
|
||||
switch v := req.Tags.(type) {
|
||||
case []interface{}:
|
||||
for _, t := range v {
|
||||
if s, ok := t.(string); ok {
|
||||
cr.Tags = append(cr.Tags, s)
|
||||
}
|
||||
}
|
||||
case json.RawMessage:
|
||||
var items []string
|
||||
if err := json.Unmarshal(v, &items); err == nil {
|
||||
cr.Tags = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal nutrition.
|
||||
if req.Nutrition != nil {
|
||||
switch v := req.Nutrition.(type) {
|
||||
case map[string]interface{}:
|
||||
cr.Calories = floatVal(v["calories"])
|
||||
cr.Protein = floatVal(v["protein_g"])
|
||||
cr.Fat = floatVal(v["fat_g"])
|
||||
cr.Carbs = floatVal(v["carbs_g"])
|
||||
case json.RawMessage:
|
||||
var nut struct {
|
||||
Calories float64 `json:"calories"`
|
||||
Protein float64 `json:"protein_g"`
|
||||
Fat float64 `json:"fat_g"`
|
||||
Carbs float64 `json:"carbs_g"`
|
||||
}
|
||||
if err := json.Unmarshal(v, &nut); err == nil {
|
||||
cr.Calories = nut.Calories
|
||||
cr.Protein = nut.Protein
|
||||
cr.Fat = nut.Fat
|
||||
cr.Carbs = nut.Carbs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
recipeID, err = r.dishRepo.Create(ctx, cr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create dish+recipe: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert bookmark.
|
||||
const q = `
|
||||
INSERT INTO user_saved_recipes (user_id, recipe_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now()
|
||||
RETURNING id, user_id, recipe_id, saved_at`
|
||||
|
||||
var usr UserSavedRecipe
|
||||
err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan(
|
||||
&usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert bookmark: %w", err)
|
||||
}
|
||||
|
||||
return r.enrichOne(ctx, &usr)
|
||||
}
|
||||
|
||||
// List returns all bookmarked recipes for userID ordered by saved_at DESC.
|
||||
func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const q = `
|
||||
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
|
||||
FROM user_saved_recipes usr
|
||||
JOIN recipes r ON r.id = usr.recipe_id
|
||||
JOIN dishes d ON d.id = r.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE usr.user_id = $1
|
||||
ORDER BY usr.saved_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, userID, lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list saved recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*UserSavedRecipe
|
||||
for rows.Next() {
|
||||
rec, err := scanUSR(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
||||
}
|
||||
if err := r.loadTags(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, rec)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns a bookmarked recipe by its bookmark ID for userID.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const q = `
|
||||
SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
r.difficulty, r.prep_time_min, r.cook_time_min, r.servings,
|
||||
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving
|
||||
FROM user_saved_recipes usr
|
||||
JOIN recipes r ON r.id = usr.recipe_id
|
||||
JOIN dishes d ON d.id = r.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||
WHERE usr.id = $1 AND usr.user_id = $2`
|
||||
|
||||
rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadTags(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadSteps(ctx, rec, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Delete removes a bookmark.
|
||||
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete saved recipe: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
const q = `
|
||||
SELECT COALESCE(dt.name, d.name) AS dish_name,
|
||||
COALESCE(dt.description, d.description) AS description,
|
||||
d.image_url, d.cuisine_slug,
|
||||
rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings,
|
||||
rec.calories_per_serving, rec.protein_per_serving,
|
||||
rec.fat_per_serving, rec.carbs_per_serving
|
||||
FROM recipes rec
|
||||
JOIN dishes d ON d.id = rec.dish_id
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||
WHERE rec.id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang)
|
||||
if err := row.Scan(
|
||||
&usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug,
|
||||
&usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings,
|
||||
&usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("enrich saved recipe: %w", err)
|
||||
}
|
||||
if err := r.loadTags(ctx, usr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadIngredients(ctx, usr, lang); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usr, r.loadSteps(ctx, usr, lang)
|
||||
}
|
||||
|
||||
func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT dt.tag_slug
|
||||
FROM dish_tags dt
|
||||
JOIN recipes rec ON rec.dish_id = dt.dish_id
|
||||
JOIN user_saved_recipes usr ON usr.recipe_id = rec.id
|
||||
WHERE usr.id = $1
|
||||
ORDER BY dt.tag_slug`, usr.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Tags = []string{}
|
||||
for rows.Next() {
|
||||
var slug string
|
||||
if err := rows.Scan(&slug); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Tags = append(usr.Tags, slug)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT COALESCE(rit.name, ri.name) AS name,
|
||||
ri.amount, ri.unit_code, ri.is_optional
|
||||
FROM recipe_ingredients ri
|
||||
LEFT JOIN recipe_ingredient_translations rit
|
||||
ON rit.ri_id = ri.id AND rit.lang = $2
|
||||
WHERE ri.recipe_id = $1
|
||||
ORDER BY ri.sort_order`, usr.RecipeID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Ingredients = []RecipeIngredient{}
|
||||
for rows.Next() {
|
||||
var ing RecipeIngredient
|
||||
if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Ingredients = append(usr.Ingredients, ing)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rs.step_number,
|
||||
COALESCE(rst.description, rs.description) AS description,
|
||||
rs.timer_seconds
|
||||
FROM recipe_steps rs
|
||||
LEFT JOIN recipe_step_translations rst
|
||||
ON rst.step_id = rs.id AND rst.lang = $2
|
||||
WHERE rs.recipe_id = $1
|
||||
ORDER BY rs.step_number`, usr.RecipeID, lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
usr.Steps = []RecipeStep{}
|
||||
for rows.Next() {
|
||||
var s RecipeStep
|
||||
if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil {
|
||||
return err
|
||||
}
|
||||
usr.Steps = append(usr.Steps, s)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanUSR(s rowScanner) (*UserSavedRecipe, error) {
|
||||
var r UserSavedRecipe
|
||||
err := s.Scan(
|
||||
&r.ID, &r.UserID, &r.RecipeID, &r.SavedAt,
|
||||
&r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug,
|
||||
&r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings,
|
||||
&r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing,
|
||||
)
|
||||
return &r, err
|
||||
}
|
||||
|
||||
// mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug.
|
||||
// Falls back to "other".
|
||||
func mapCuisineSlug(cuisine string) string {
|
||||
known := map[string]string{
|
||||
"russian": "russian",
|
||||
"italian": "italian",
|
||||
"french": "french",
|
||||
"chinese": "chinese",
|
||||
"japanese": "japanese",
|
||||
"korean": "korean",
|
||||
"mexican": "mexican",
|
||||
"mediterranean": "mediterranean",
|
||||
"indian": "indian",
|
||||
"thai": "thai",
|
||||
"american": "american",
|
||||
"georgian": "georgian",
|
||||
"spanish": "spanish",
|
||||
"german": "german",
|
||||
"middle_eastern":"middle_eastern",
|
||||
"turkish": "turkish",
|
||||
"greek": "greek",
|
||||
"vietnamese": "vietnamese",
|
||||
"asian": "other",
|
||||
"european": "other",
|
||||
}
|
||||
if s, ok := known[cuisine]; ok {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func strVal(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func floatVal(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
60
backend/internal/domain/tag/handler.go
Normal file
60
backend/internal/domain/tag/handler.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type tagItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"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
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
60
backend/internal/domain/units/handler.go
Normal file
60
backend/internal/domain/units/handler.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package units
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type unitItem struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
71
backend/internal/domain/user/calories.go
Normal file
71
backend/internal/domain/user/calories.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Activity level multipliers (Mifflin-St Jeor).
|
||||
var activityMultiplier = map[string]float64{
|
||||
"low": 1.375,
|
||||
"moderate": 1.55,
|
||||
"high": 1.725,
|
||||
}
|
||||
|
||||
// Goal adjustments in kcal.
|
||||
var goalAdjustment = map[string]float64{
|
||||
"lose": -500,
|
||||
"maintain": 0,
|
||||
"gain": 300,
|
||||
}
|
||||
|
||||
// AgeFromDOB computes age in years from a "YYYY-MM-DD" string. Returns nil on error.
|
||||
func AgeFromDOB(dob *string) *int {
|
||||
if dob == nil {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", *dob)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
years := now.Year() - t.Year()
|
||||
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
|
||||
years--
|
||||
}
|
||||
return &years
|
||||
}
|
||||
|
||||
// CalculateDailyCalories computes the daily calorie target using the
|
||||
// Mifflin-St Jeor equation. Returns nil if any required parameter is missing.
|
||||
func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int {
|
||||
if heightCM == nil || weightKG == nil || age == nil || gender == nil || activity == nil || goal == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BMR = 10 * weight(kg) + 6.25 * height(cm) - 5 * age
|
||||
bmr := 10**weightKG + 6.25*float64(*heightCM) - 5*float64(*age)
|
||||
|
||||
switch *gender {
|
||||
case "male":
|
||||
bmr += 5
|
||||
case "female":
|
||||
bmr -= 161
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
mult, ok := activityMultiplier[*activity]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
adj, ok := goalAdjustment[*goal]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
tdee := bmr * mult
|
||||
result := int(math.Round(tdee + adj))
|
||||
return &result
|
||||
}
|
||||
44
backend/internal/domain/user/entity.go
Normal file
44
backend/internal/domain/user/entity.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
FirebaseUID string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
DateOfBirth *string `json:"date_of_birth"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
DailyCalories *int `json:"daily_calories"`
|
||||
Plan string `json:"plan"`
|
||||
Preferences json.RawMessage `json:"preferences"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
DateOfBirth *string `json:"date_of_birth"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
Preferences *json.RawMessage `json:"preferences"`
|
||||
DailyCalories *int `json:"-"` // internal, set by service
|
||||
}
|
||||
|
||||
// HasBodyParams returns true if any body parameter is being updated
|
||||
// that would require recalculation of daily calories.
|
||||
func (r *UpdateProfileRequest) HasBodyParams() bool {
|
||||
return r.HeightCM != nil || r.WeightKG != nil || r.DateOfBirth != nil ||
|
||||
r.Gender != nil || r.Activity != nil || r.Goal != nil
|
||||
}
|
||||
78
backend/internal/domain/user/handler.go
Normal file
78
backend/internal/domain/user/handler.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.UpdateProfile(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("failed to write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
46
backend/internal/domain/user/mocks/repository.go
Normal file
46
backend/internal/domain/user/mocks/repository.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/domain/user"
|
||||
)
|
||||
|
||||
type MockUserRepository struct {
|
||||
UpsertByFirebaseUIDFn func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error)
|
||||
GetByIDFn func(ctx context.Context, id string) (*user.User, error)
|
||||
UpdateFn func(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error)
|
||||
UpdateInTxFn func(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error)
|
||||
SetRefreshTokenFn func(ctx context.Context, id, token string, expiresAt time.Time) error
|
||||
FindByRefreshTokenFn func(ctx context.Context, token string) (*user.User, error)
|
||||
ClearRefreshTokenFn func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return m.UpsertByFirebaseUIDFn(ctx, uid, email, name, avatarURL)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||||
return m.GetByIDFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) Update(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error) {
|
||||
return m.UpdateFn(ctx, id, req)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) UpdateInTx(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error) {
|
||||
return m.UpdateInTxFn(ctx, id, profileReq, caloriesReq)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return m.SetRefreshTokenFn(ctx, id, token, expiresAt)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) FindByRefreshToken(ctx context.Context, token string) (*user.User, error) {
|
||||
return m.FindByRefreshTokenFn(ctx, token)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) ClearRefreshToken(ctx context.Context, id string) error {
|
||||
return m.ClearRefreshTokenFn(ctx, id)
|
||||
}
|
||||
223
backend/internal/domain/user/repository.go
Normal file
223
backend/internal/domain/user/repository.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// UserRepository defines the persistence interface for user data.
|
||||
type UserRepository interface {
|
||||
UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
|
||||
GetByID(ctx context.Context, id string) (*User, error)
|
||||
Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
|
||||
UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error)
|
||||
SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
|
||||
FindByRefreshToken(ctx context.Context, token string) (*User, error)
|
||||
ClearRefreshToken(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) {
|
||||
var avatarPtr *string
|
||||
if avatarURL != "" {
|
||||
avatarPtr = &avatarURL
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO users (firebase_uid, email, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
name = CASE WHEN users.name = '' THEN EXCLUDED.name ELSE users.name END,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = now()
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr))
|
||||
}
|
||||
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users WHERE id = $1`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, id))
|
||||
}
|
||||
|
||||
func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) {
|
||||
query, args := buildUpdateQuery(id, req)
|
||||
if query == "" {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, args...))
|
||||
}
|
||||
|
||||
// buildUpdateQuery constructs a dynamic UPDATE query from the request fields.
|
||||
// Returns empty string if there are no fields to update.
|
||||
func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{}) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Name != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
|
||||
args = append(args, *req.Name)
|
||||
argIdx++
|
||||
}
|
||||
if req.HeightCM != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("height_cm = $%d", argIdx))
|
||||
args = append(args, *req.HeightCM)
|
||||
argIdx++
|
||||
}
|
||||
if req.WeightKG != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("weight_kg = $%d", argIdx))
|
||||
args = append(args, *req.WeightKG)
|
||||
argIdx++
|
||||
}
|
||||
if req.DateOfBirth != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("date_of_birth = $%d", argIdx))
|
||||
args = append(args, *req.DateOfBirth)
|
||||
argIdx++
|
||||
}
|
||||
if req.Gender != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("gender = $%d", argIdx))
|
||||
args = append(args, *req.Gender)
|
||||
argIdx++
|
||||
}
|
||||
if req.Activity != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("activity = $%d", argIdx))
|
||||
args = append(args, *req.Activity)
|
||||
argIdx++
|
||||
}
|
||||
if req.Goal != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("goal = $%d", argIdx))
|
||||
args = append(args, *req.Goal)
|
||||
argIdx++
|
||||
}
|
||||
if req.Preferences != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("preferences = $%d", argIdx))
|
||||
args = append(args, string(*req.Preferences))
|
||||
argIdx++
|
||||
}
|
||||
if req.DailyCalories != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("daily_calories = $%d", argIdx))
|
||||
args = append(args, *req.DailyCalories)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, "updated_at = now()")
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE users SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`,
|
||||
strings.Join(setClauses, ", "), argIdx)
|
||||
args = append(args, id)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// First update: profile fields
|
||||
query, args := buildUpdateQuery(id, profileReq)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("no fields to update")
|
||||
}
|
||||
updated, err := r.scanUser(tx.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
|
||||
// Second update: calories (if provided)
|
||||
if caloriesReq != nil {
|
||||
query, args = buildUpdateQuery(id, *caloriesReq)
|
||||
if query != "" {
|
||||
updated, err = r.scanUser(tx.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update calories: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
query := `UPDATE users SET refresh_token = $2, token_expires_at = $3, updated_at = now() WHERE id = $1`
|
||||
_, err := r.pool.Exec(ctx, query, id, token, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users
|
||||
WHERE refresh_token = $1 AND token_expires_at > now()`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, token))
|
||||
}
|
||||
|
||||
func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error {
|
||||
query := `UPDATE users SET refresh_token = NULL, token_expires_at = NULL, updated_at = now() WHERE id = $1`
|
||||
_, err := r.pool.Exec(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (r *Repository) scanUser(row scannable) (*User, error) {
|
||||
var u User
|
||||
var prefs []byte
|
||||
var dob *time.Time
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL,
|
||||
&u.HeightCM, &u.WeightKG, &dob, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
||||
&u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
if dob != nil {
|
||||
s := dob.Format("2006-01-02")
|
||||
u.DateOfBirth = &s
|
||||
}
|
||||
u.Preferences = json.RawMessage(prefs)
|
||||
return &u, nil
|
||||
}
|
||||
127
backend/internal/domain/user/service.go
Normal file
127
backend/internal/domain/user/service.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
func NewService(repo UserRepository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error) {
|
||||
return s.repo.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error) {
|
||||
if err := validateProfileRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !req.HasBodyParams() {
|
||||
updated, err := s.repo.Update(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Need to update profile + recalculate calories in a single transaction.
|
||||
// First, get current user to merge with incoming fields for calorie calculation.
|
||||
current, err := s.repo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
// Merge current values with request to compute calories
|
||||
height := current.HeightCM
|
||||
if req.HeightCM != nil {
|
||||
height = req.HeightCM
|
||||
}
|
||||
weight := current.WeightKG
|
||||
if req.WeightKG != nil {
|
||||
weight = req.WeightKG
|
||||
}
|
||||
dob := current.DateOfBirth
|
||||
if req.DateOfBirth != nil {
|
||||
dob = req.DateOfBirth
|
||||
}
|
||||
age := AgeFromDOB(dob)
|
||||
gender := current.Gender
|
||||
if req.Gender != nil {
|
||||
gender = req.Gender
|
||||
}
|
||||
activity := current.Activity
|
||||
if req.Activity != nil {
|
||||
activity = req.Activity
|
||||
}
|
||||
goal := current.Goal
|
||||
if req.Goal != nil {
|
||||
goal = req.Goal
|
||||
}
|
||||
|
||||
calories := CalculateDailyCalories(height, weight, age, gender, activity, goal)
|
||||
|
||||
var calReq *UpdateProfileRequest
|
||||
if calories != nil {
|
||||
calReq = &UpdateProfileRequest{DailyCalories: calories}
|
||||
}
|
||||
|
||||
updated, err := s.repo.UpdateInTx(ctx, userID, req, calReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func validateProfileRequest(req UpdateProfileRequest) error {
|
||||
if req.HeightCM != nil {
|
||||
if *req.HeightCM < 100 || *req.HeightCM > 250 {
|
||||
return fmt.Errorf("height_cm must be between 100 and 250")
|
||||
}
|
||||
}
|
||||
if req.WeightKG != nil {
|
||||
if *req.WeightKG < 30 || *req.WeightKG > 300 {
|
||||
return fmt.Errorf("weight_kg must be between 30 and 300")
|
||||
}
|
||||
}
|
||||
if req.DateOfBirth != nil {
|
||||
t, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("date_of_birth must be in YYYY-MM-DD format")
|
||||
}
|
||||
if t.After(time.Now()) {
|
||||
return fmt.Errorf("date_of_birth cannot be in the future")
|
||||
}
|
||||
age := AgeFromDOB(req.DateOfBirth)
|
||||
if *age < 10 || *age > 120 {
|
||||
return fmt.Errorf("date_of_birth must yield an age between 10 and 120")
|
||||
}
|
||||
}
|
||||
if req.Gender != nil {
|
||||
if *req.Gender != "male" && *req.Gender != "female" {
|
||||
return fmt.Errorf("gender must be 'male' or 'female'")
|
||||
}
|
||||
}
|
||||
if req.Activity != nil {
|
||||
switch *req.Activity {
|
||||
case "low", "moderate", "high":
|
||||
default:
|
||||
return fmt.Errorf("activity must be 'low', 'moderate', or 'high'")
|
||||
}
|
||||
}
|
||||
if req.Goal != nil {
|
||||
switch *req.Goal {
|
||||
case "lose", "maintain", "gain":
|
||||
default:
|
||||
return fmt.Errorf("goal must be 'lose', 'maintain', or 'gain'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user