Add offline meal journal and secure sync

This commit is contained in:
Hamza-Ayed
2026-10-04 02:13:23 +03:00
parent d892e3d981
commit 27fc2d5eb1
12 changed files with 936 additions and 23 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/backend/JwtToken.php';
require_once dirname(__DIR__, 3) . '/backend/ApiAuth.php';
require_once __DIR__ . '/_bootstrap.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
api_json(['error' => '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);
}