prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t'); $stmt->execute(['t' => $tenantId]); $row = $stmt->fetch(); return $row ?: null; } // ── POST /api/shopping ──────────────────────────────────────── // Client pushes the currently-active card's content. `updated_at` // is the client's timestamp — used for lose-the-race checks. if ($route === '/api/shopping' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $uid = trim((string)($input['card_uid'] ?? '')); $title = (string)($input['title'] ?? ''); $body = (string)($input['body'] ?? ''); $clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s'); if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); // No server-side staleness guard: the client decides whether to // push by diffing its last-synced body against the current card, // not by timestamp, so cross-timezone clock comparisons can't // silently block edits. Last-writer per tick wins — phone-toggle // races lose at most one tick of flips (rare by design). $now = gmdate('Y-m-d H:i:s'); $db->prepare(' INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) VALUES (:t, :u, :ti, :b, :c, :s) ON CONFLICT(tenant_id) DO UPDATE SET card_uid = excluded.card_uid, title = excluded.title, body = excluded.body, updated_at_client = excluded.updated_at_client, updated_at_server = excluded.updated_at_server ')->execute([ 't' => $clientTenant['id'], 'u' => $uid, 'ti' => $title, 'b' => $body, 'c' => $clientTs, 's' => $now, ]); jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]); } // ── GET /api/shopping ──────────────────────────────────────── // Both the client (to pull phone-made toggles back) and the // session-auth'd phone UI poll this. if ($route === '/api/shopping' && $method === 'GET') { if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401); $state = shoppingLoad($db, (int)$tenant['id']); if (!$state) jsonResponse(['ok' => true, 'state' => null]); jsonResponse(['ok' => true, 'state' => $state]); } // ── POST /api/shopping/check ───────────────────────────────── // Phone-side toggle. Body: { line: "- [ ] milk", checked: true }. // We match the exact line once to keep merges simple; if the line // isn't found (hyprcrd just rewrote it), the caller reloads and // retries. Bumps updated_at_server so the next client pull sees // the change. if ($route === '/api/shopping/check' && $method === 'POST') { if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $line = (string)($input['line'] ?? ''); $checked = !empty($input['checked']); if ($line === '') jsonResponse(['error' => 'line required'], 400); $state = shoppingLoad($db, (int)$sessionTenant['id']); if (!$state) jsonResponse(['error' => 'no active list'], 404); $body = $state['body']; $lines = preg_split("/\r\n|\n|\r/", $body); $hit = false; foreach ($lines as $i => $l) { if ($l !== $line) continue; $toggled = $checked ? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1) : preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1); if ($toggled === $l) continue; // not a checkbox line $lines[$i] = $toggled; $hit = true; break; } if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409); $newBody = implode("\n", $lines); $now = gmdate('Y-m-d H:i:s'); $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t') ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]); jsonResponse(['ok' => true, 'updated_at_server' => $now]); } // Shopping list lives inside the relay UI as a regular view (see // relay/ui/layout.php + app.js). No separate /shopping page — login // and navigation are the same as for every other relay feature.