'method_not_allowed'], 405); } $auth = ApiAuth::bearerClaims(); function meal_api_number($value, string $field, float $max): float { if (!is_int($value) && !is_float($value)) api_json(['error' => 'invalid_' . $field], 400); $number = (float) $value; if (!is_finite($number) || $number < 0 || $number > $max) api_json(['error' => 'invalid_' . $field], 400); return $number; } function meal_api_date(string $value): ?DateTimeImmutable { try { $date = new DateTimeImmutable($value, new DateTimeZone('UTC')); return $date->setTimezone(new DateTimeZone('UTC')); } catch (Throwable $exception) { return null; } } try { $db = Database::getInstance(); if ($method === 'GET') { $fromRaw = $_GET['from'] ?? gmdate('Y-m-d', strtotime('-30 days')); $toRaw = $_GET['to'] ?? gmdate('Y-m-d', strtotime('+1 day')); if (!is_string($fromRaw) || !is_string($toRaw)) api_json(['error' => 'invalid_date_range'], 400); $from = meal_api_date($fromRaw); $to = meal_api_date($toRaw); if ($from === null || $to === null || $from >= $to || $to->diff($from)->days > 92) api_json(['error' => 'invalid_date_range'], 400); $userId = $auth['user_id']; $fromSql = $from->format('Y-m-d H:i:s'); $toSql = $to->format('Y-m-d H:i:s'); $query = $db->prepare('SELECT client_meal_uuid, name, meal_type, calories, protein_grams, carbohydrate_grams, fat_grams, consumed_at, source, notes, revision, updated_at FROM meal_entries WHERE user_id = ? AND consumed_at >= ? AND consumed_at < ? ORDER BY consumed_at DESC LIMIT 2000'); $query->bind_param('iss', $userId, $fromSql, $toSql); $query->execute(); $result = $query->get_result(); $entries = []; while ($row = $result->fetch_assoc()) { foreach (['calories', 'protein_grams', 'carbohydrate_grams', 'fat_grams'] as $key) $row[$key] = (float) $row[$key]; $row['revision'] = (int) $row['revision']; $row['consumed_at'] = gmdate('Y-m-d\\TH:i:s\\Z', strtotime($row['consumed_at'] . ' UTC')); $row['updated_at'] = gmdate('Y-m-d\\TH:i:s\\Z', strtotime($row['updated_at'] . ' UTC')); $entries[] = $row; } $query->close(); header('Cache-Control: no-store'); api_json(['meals' => $entries]); } $rawBody = file_get_contents('php://input') ?: ''; if (strlen($rawBody) > 16000) api_json(['error' => 'payload_too_large'], 413); $body = json_decode($rawBody, true); if (!is_array($body)) api_json(['error' => 'invalid_meal_payload'], 400); $clientUuid = $body['client_meal_uuid'] ?? null; if (!is_string($clientUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $clientUuid)) api_json(['error' => 'invalid_client_meal_uuid'], 400); $name = $body['name'] ?? null; if (!is_string($name) || trim($name) === '' || mb_strlen($name) > 180) api_json(['error' => 'invalid_name'], 400); $mealType = $body['meal_type'] ?? null; if (!is_string($mealType) || !in_array($mealType, ['breakfast', 'lunch', 'dinner', 'snack'], true)) api_json(['error' => 'invalid_meal_type'], 400); $source = $body['source'] ?? 'manual'; // Until a server-side vision provider exists, clients cannot label guesses as AI analysis. if ($source !== 'manual') api_json(['error' => 'unsupported_meal_source'], 400); $calories = meal_api_number($body['calories'] ?? null, 'calories', 10000); $protein = meal_api_number($body['protein_grams'] ?? 0, 'protein_grams', 1000); $carbs = meal_api_number($body['carbohydrate_grams'] ?? 0, 'carbohydrate_grams', 1000); $fat = meal_api_number($body['fat_grams'] ?? 0, 'fat_grams', 1000); $consumedRaw = $body['consumed_at'] ?? null; $consumed = is_string($consumedRaw) ? meal_api_date($consumedRaw) : null; if ($consumed === null) api_json(['error' => 'invalid_consumed_at'], 400); $consumedSql = $consumed->format('Y-m-d H:i:s'); $notes = $body['notes'] ?? ''; if (!is_string($notes) || mb_strlen($notes) > 1000) api_json(['error' => 'invalid_notes'], 400); $name = trim($name); $notes = trim($notes); $userId = $auth['user_id']; $connection = $db->getConnection(); $connection->begin_transaction(); $lookup = $db->prepare('SELECT id FROM meal_entries WHERE user_id = ? AND client_meal_uuid = ? FOR UPDATE'); $lookup->bind_param('is', $userId, $clientUuid); $lookup->execute(); $existing = $lookup->get_result()->fetch_assoc(); $lookup->close(); if ($existing) { $update = $db->prepare('UPDATE meal_entries SET name = ?, meal_type = ?, calories = ?, protein_grams = ?, carbohydrate_grams = ?, fat_grams = ?, consumed_at = ?, source = \'manual\', notes = ?, revision = revision + 1 WHERE user_id = ? AND client_meal_uuid = ?'); $update->bind_param('ssddddssis', $name, $mealType, $calories, $protein, $carbs, $fat, $consumedSql, $notes, $userId, $clientUuid); $update->execute(); $update->close(); $status = 200; } else { $insert = $db->prepare('INSERT INTO meal_entries (user_id, client_meal_uuid, name, meal_type, calories, protein_grams, carbohydrate_grams, fat_grams, consumed_at, source, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, \'manual\', ?)'); $insert->bind_param('isssddddss', $userId, $clientUuid, $name, $mealType, $calories, $protein, $carbs, $fat, $consumedSql, $notes); $insert->execute(); $insert->close(); $status = 201; } $connection->commit(); api_json(['status' => 'saved', 'client_meal_uuid' => $clientUuid], $status); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('Meal journal request failed: ' . $exception->getMessage()); api_json(['error' => 'service_unavailable'], 503); }