/**
* Бізнес-логіка щоденника харчування, статистики та пошуку продуктів.
*
* Цей модуль відповідає за:
* - збереження записів про спожиту їжу
* - обчислення денних і періодичних показників харчування
* - роботу з власними продуктами користувача
* - пошук продуктів через зовнішній USDA FoodData Central API
* - формування агрегованих даних для dashboard.
*
* Основне бізнес-правило модуля полягає в тому, що всі записи,
* статистика та власні продукти прив’язані до конкретного
* авторизованого користувача.
*/
const pool = require('../config/db')
const https = require('https')
/**
* Повертає список записів харчування поточного користувача за обрану дату.
*
* Якщо дата не передана, використовує поточну дату. Результат
* сортується за часом створення у зростаючому порядку.
* @param {object} req HTTP-запит з необов'язковою датою в query-параметрі.
* @param {object} res HTTP-відповідь зі списком записів харчування або повідомленням про помилку.
* @returns {Promise<void>}
*/
const getFoodLogs = async (req, res) => {
const date = req.query.date || new Date().toISOString().split('T')[0]
try {
const result = await pool.query(
`SELECT * FROM food_logs
WHERE user_id = $1 AND log_date = $2
ORDER BY created_at ASC`,
[req.user.id, date]
)
res.json(result.rows)
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Повертає агреговану статистику харчування користувача за останні N днів.
*
* Обчислює сумарні калорії, білки, жири та вуглеводи для кожної дати
* у вибраному часовому проміжку. Якщо параметр days не передано,
* використовується значення 7.
* @param {object} req HTTP-запит з необов'язковим query-параметром days.
* @param {object} res HTTP-відповідь зі статистикою по днях або повідомленням про помилку.
* @returns {Promise<void>}
*/
const getFoodStats = async (req, res) => {
const days = parseInt(req.query.days) || 7
try {
const result = await pool.query(
`SELECT
log_date,
ROUND(SUM(kcal)::numeric, 1) AS total_kcal,
ROUND(SUM(protein_g)::numeric, 1) AS total_protein,
ROUND(SUM(fat_g)::numeric, 1) AS total_fat,
ROUND(SUM(carbs_g)::numeric, 1) AS total_carbs
FROM food_logs
WHERE user_id = $1
AND log_date >= CURRENT_DATE - INTERVAL '1 day' * $2
GROUP BY log_date
ORDER BY log_date ASC`,
[req.user.id, days - 1]
)
res.json(result.rows)
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Додає новий запис про спожиту їжу до щоденника користувача.
*
* Перевіряє наявність обов'язкових полів, створює запис у таблиці food_logs
* і повертає створений об'єкт. Якщо дата не передана, використовує поточну дату.
* @param {object} req HTTP-запит з даними запису харчування в тілі запиту.
* @param {object} res HTTP-відповідь зі створеним записом або повідомленням про помилку.
* @returns {Promise<void>}
*/
const addFoodLog = async (req, res) => {
const { log_date, meal_type, food_name, amount_g,
kcal, protein_g, fat_g, carbs_g, usda_fdc_id } = req.body
if(!food_name || !amount_g || !meal_type) {
return res.status(400).json({ error: 'Назва, кількість та прийом їжі обовʼязкові' })
}
try {
const result = await pool.query(
`INSERT INTO food_logs
(user_id, log_date, meal_type, food_name, amount_g,
kcal, protein_g, fat_g, carbs_g, usda_fdc_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
req.user.id,
log_date || new Date().toISOString().split('T')[0],
meal_type, food_name, amount_g,
kcal || 0, protein_g || 0, fat_g || 0, carbs_g || 0,
usda_fdc_id || null
]
)
res.status(201).json(result.rows[0])
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Видаляє запис про їжу, якщо він належить поточному користувачу.
*
* Виконує видалення за ідентифікатором запису та user_id. Якщо запис не знайдено,
* повертає 404.
* @param {object} req HTTP-запит з id запису в параметрах маршруту.
* @param {object} res HTTP-відповідь з підтвердженням видалення або повідомленням про помилку.
* @returns {Promise<void>}
*/
const deleteFoodLog = async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM food_logs WHERE id = $1 AND user_id = $2 RETURNING id',
[req.params.id, req.user.id]
)
if (result.rows.length == 0) {
return res.status(404).json({error: 'Запис не знайдено'})
}
res.json({ message: 'Видалено' })
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Повертає список власних продуктів, створених поточним користувачем.
*
* Результат сортується за назвою у алфавітному порядку.
* @param {object} req HTTP-запит з даними авторизованого користувача.
* @param {object} res HTTP-відповідь зі списком користувацьких продуктів або повідомленням про помилку.
* @returns {Promise<void>}
*/
const getCustomFoods = async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM custom_foods WHERE user_id = $1 ORDER BY name ASC',
[req.user.id]
)
res.json(result.rows)
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Додає власний продукт до персонального списку користувача.
*
* Перевіряє наявність обов'язкових полів і створює запис у таблиці custom_foods.
* Значення білків, жирів і вуглеводів за замовчуванням дорівнюють 0, якщо їх не передано.
* @param {object} req HTTP-запит з даними продукту в тілі запиту.
* @param {object} res HTTP-відповідь зі створеним продуктом або повідомленням про помилку.
* @returns {Promise<void>}
*/
const addCustomFood = async (req, res) => {
const { name, kcal_per100, protein_per100, fat_per100, carbs_per100 } = req.body
if (!name || !kcal_per100) {
return res.status(400).json({ error: 'Назва та калорії обовʼязкові' })
}
try {
const result = await pool.query(
`INSERT INTO custom_foods
(user_id, name, kcal_per100, protein_per100, fat_per100, carbs_per100)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[req.user.id, name, kcal_per100,
protein_per100 || 0, fat_per100 || 0, carbs_per100 || 0]
)
res.status(201).json(result.rows[0])
} catch {
res.status(500).json({ error: 'Помилка сервера' })
}
}
/**
* Виконує пошук продуктів через USDA FoodData Central API і повертає
* нормалізований список продуктів з основними харчовими показниками.
*
* Отримує пошуковий запит з req.query.q, звертається до зовнішнього API,
* виділяє калорії, білки, жири та вуглеводи й перетворює відповідь у формат,
* який використовує клієнтська частина застосунку.
*
* Алгоритм нормалізації відповіді USDA:
* зовнішній API повертає поживні значення як масив foodNutrients,
* де кожен показник визначається числовим nutrientId.
* Для роботи застосунку відповідь перетворюється у спрощену структуру
* з полями kcal_per100, protein_per100, fat_per100 і carbs_per100.
*
* Використані nutrientId:
* - 1008 це калорії
* - 1003 це білки
* - 1004 це жири
* - 1005 це вуглеводи
* @param {object} req HTTP-запит з пошуковим рядком у query-параметрі q.
* @param {object} res HTTP-відповідь зі списком знайдених продуктів або повідомленням про помилку.
* @returns {Promise<void>}
*/
const searchUSDA = async (req, res) => {
const query = req.query.q
if(!query) return res.status(400).json({ error: 'Введіть запит' })
const apiKey = process.env.USDA_API_KEY
if (!apiKey) return res.status(500).json({ error: 'USDA API ключ не налаштований' })
const url = `https://api.nal.usda.gov/fdc/v1/foods/search?query=${encodeURIComponent(query)}&pageSize=10&api_key=${apiKey}`
https.get(url, (apiRes) => {
let data = ''
apiRes.on('data', chunk => { data += chunk })
apiRes.on('end', () => {
try {
const json = JSON.parse(data)
const foods = (json.foods || []).map(f => {
const nutrients = {}
;(f.foodNutrients || []).forEach(n => {
if (n.nutrientId === 1008) nutrients.kcal = n.value
if (n.nutrientId === 1003) nutrients.protein = n.value
if (n.nutrientId === 1004) nutrients.fat = n.value
if (n.nutrientId === 1005) nutrients.carbs = n.value
})
return {
fdcId: f.fdcId,
name: f.description,
brand: f.brandOwner || null,
kcal_per100: nutrients.kcal || 0,
protein_per100: nutrients.protein || 0,
fat_per100: nutrients.fat || 0,
carbs_per100: nutrients.carbs || 0
}
})
res.json(foods)
} catch (e) {
console.error('помилка парсингу USDA відповіді', e)
res.status(500).json({ error: 'Помилка обробки відповіді USDA' })
}
})
}).on('error', () => {
res.status(500).json({ error: 'Помилка підключення до USDA API' })
})
}
module.exports = {
getFoodLogs, getFoodStats, addFoodLog, deleteFoodLog,
getCustomFoods, addCustomFood, searchUSDA,
}