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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user