Root cause of the doubled lists: the relay DB had stale rows from
previous activations that were never deleted. The composite PK
prevented same-uid duplicates but not leftover rows from cards that
were deactivated without a successful DELETE.
Fixes:
- Client tick sends DELETE { keep: [active uids] } after every
push cycle. The relay drops all rows for this tenant that aren't
in the keep list — cleans up migration leftovers, failed deletes,
old single-list sentinels, everything in one shot.
- Relay DELETE endpoint accepts { keep: [...] } for bulk cleanup
alongside the existing { card_uid } single-delete.
- Relay layout.php adds cache-busting query strings on style.css
and app.js via filemtime() so phone browsers pick up new code
on deploy instead of serving stale cached versions.
- showView persists the active view name to localStorage; after
login the relay restores it instead of always landing on compose.
- Shopping-list-box border/background removed — items have their
own chrome, the box is just a flex grouper now.
- View title is always "Shopping"; each list box keeps its own
header with the card title. No more doubled name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
134 lines
6.4 KiB
PHP
134 lines
6.4 KiB
PHP
<?php
|
|
// Shopping-list routes:
|
|
// POST /api/shopping (client) upsert one list {card_uid, title, body}
|
|
// GET /api/shopping (client + session) all active lists for tenant
|
|
// POST /api/shopping/check (session) toggle one line {card_uid, line, checked}
|
|
// DELETE /api/shopping (client) remove a deactivated list {card_uid}
|
|
//
|
|
// Multiple lists per tenant (composite PK: tenant_id + card_uid).
|
|
// Plaintext on purpose — shopping-list content is low-stakes.
|
|
|
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
$clientTenant = $clientTenant ?? authClient($db);
|
|
$tenant = $sessionTenant ?: $clientTenant;
|
|
|
|
function shoppingLoadAll(PDO $db, int $tenantId): array {
|
|
// Exclude __inactive__ sentinel rows left over from the old
|
|
// single-list era (clearRemote used to write a dummy row instead
|
|
// of deleting).
|
|
$stmt = $db->prepare("SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid != '__inactive__' ORDER BY position, title");
|
|
$stmt->execute(['t' => $tenantId]);
|
|
return $stmt->fetchAll() ?: [];
|
|
}
|
|
|
|
function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array {
|
|
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u');
|
|
$stmt->execute(['t' => $tenantId, 'u' => $cardUid]);
|
|
$row = $stmt->fetch();
|
|
return $row ?: null;
|
|
}
|
|
|
|
// ── POST /api/shopping ────────────────────────────────────────
|
|
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'] ?? '');
|
|
$pos = (int)($input['position'] ?? 0);
|
|
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
|
|
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
|
|
|
$now = gmdate('Y-m-d H:i:s');
|
|
$db->prepare('
|
|
INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server)
|
|
VALUES (:t, :u, :ti, :b, :p, :c, :s)
|
|
ON CONFLICT(tenant_id, card_uid) DO UPDATE SET
|
|
title = excluded.title,
|
|
body = excluded.body,
|
|
position = excluded.position,
|
|
updated_at_client = excluded.updated_at_client,
|
|
updated_at_server = excluded.updated_at_server
|
|
')->execute([
|
|
't' => $clientTenant['id'],
|
|
'u' => $uid,
|
|
'ti' => $title,
|
|
'b' => $body,
|
|
'p' => $pos,
|
|
'c' => $clientTs,
|
|
's' => $now,
|
|
]);
|
|
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
|
|
}
|
|
|
|
// ── DELETE /api/shopping ─────────────────────────────────────
|
|
// Two modes:
|
|
// { card_uid: "123" } → delete one specific list
|
|
// { keep: ["123", "456"] } → delete everything EXCEPT these
|
|
if ($route === '/api/shopping' && $method === 'DELETE') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$keep = is_array($input['keep'] ?? null) ? $input['keep'] : null;
|
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
|
|
|
if ($keep !== null) {
|
|
// Bulk cleanup: keep only the listed card_uids, drop the rest.
|
|
if (count($keep)) {
|
|
$placeholders = implode(',', array_fill(0, count($keep), '?'));
|
|
$params = array_merge([$clientTenant['id']], array_map('strval', $keep));
|
|
$db->prepare("DELETE FROM shopping_state WHERE tenant_id = ? AND card_uid NOT IN ($placeholders)")
|
|
->execute($params);
|
|
} else {
|
|
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t')
|
|
->execute(['t' => $clientTenant['id']]);
|
|
}
|
|
jsonResponse(['ok' => true]);
|
|
}
|
|
|
|
if ($uid === '') jsonResponse(['error' => 'card_uid or keep[] required'], 400);
|
|
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
|
|
->execute(['t' => $clientTenant['id'], 'u' => $uid]);
|
|
jsonResponse(['ok' => true]);
|
|
}
|
|
|
|
// ── GET /api/shopping ────────────────────────────────────────
|
|
if ($route === '/api/shopping' && $method === 'GET') {
|
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$lists = shoppingLoadAll($db, (int)$tenant['id']);
|
|
jsonResponse(['ok' => true, 'lists' => $lists]);
|
|
}
|
|
|
|
// ── POST /api/shopping/check ─────────────────────────────────
|
|
if ($route === '/api/shopping/check' && $method === 'POST') {
|
|
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
|
$line = (string)($input['line'] ?? '');
|
|
$checked = !empty($input['checked']);
|
|
if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
|
|
|
|
$state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid);
|
|
if (!$state) jsonResponse(['error' => 'list not found'], 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;
|
|
$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 AND card_uid = :u')
|
|
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
|
|
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
|
|
}
|