From a968bf54109d9c028fc17620f03c1e87d2f234aa Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 10 Jul 2026 13:03:23 +0200 Subject: [PATCH 1/3] feat(api): add paginated potato history endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/potatoes returning the authenticated user's individual potato transactions without message content — just id, amount, direction (sent/received), counterparty user_id, and created timestamp. Designed for exporting potato history into external systems for over-time analysis. Query parameters: - page: page number (CakePHP NumericPaginator) - per_page: page size, default 100, capped at 500 - direction: 'sent' or 'received' to filter; omit for both The response wraps items in a 'potatoes' array plus a 'pagination' object (page, per_page, total, total_pages, has_next) so consumers can walk all pages. Only 'page' is allowed as a paginator parameter; sorting is fixed to created DESC. Works with both session auth and the existing Bearer API token authenticator on the /api scope, so external systems can pull data using the token shown on the profile page. --- config/routes.php | 2 + src/Controller/Api/PotatoesController.php | 84 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/Controller/Api/PotatoesController.php diff --git a/config/routes.php b/config/routes.php index f9f8f2b4..e1785e55 100644 --- a/config/routes.php +++ b/config/routes.php @@ -68,6 +68,8 @@ $builder->scope('/api', function (RouteBuilder $builder): void { $builder->get('/leaderboard', ['prefix' => 'Api', 'controller' => 'LeaderBoard', 'action' => 'get']); + $builder->get('/potatoes', ['prefix' => 'Api', 'controller' => 'Potatoes', 'action' => 'list']); + $builder->get('/users', ['prefix' => 'Api', 'controller' => 'Users', 'action' => 'list']); $builder->get('/user', ['prefix' => 'Api', 'controller' => 'Users', 'action' => 'get']); $builder->patch('/user', ['prefix' => 'Api', 'controller' => 'Users', 'action' => 'edit']); diff --git a/src/Controller/Api/PotatoesController.php b/src/Controller/Api/PotatoesController.php new file mode 100644 index 00000000..bf389905 --- /dev/null +++ b/src/Controller/Api/PotatoesController.php @@ -0,0 +1,84 @@ +Authentication->getIdentityData('id'); + + $messagesTable = $this->fetchTable('Messages'); + $query = $messagesTable->find() + ->select([ + 'Messages.id', + 'Messages.sender_user_id', + 'Messages.receiver_user_id', + 'Messages.amount', + 'Messages.created', + ]); + + $direction = $this->request->getQuery('direction'); + switch ($direction) { + case 'sent': + $query->where(['Messages.sender_user_id' => $userId]); + break; + case 'received': + $query->where(['Messages.receiver_user_id' => $userId]); + break; + default: + $query->where([ + 'OR' => [ + 'Messages.sender_user_id' => $userId, + 'Messages.receiver_user_id' => $userId, + ], + ]); + } + + $potatoes = $this->paginate($query, [ + 'allowedParameters' => ['page'], + 'limit' => min(max((int)$this->request->getQuery('per_page', '100'), 1), 500), + 'maxLimit' => 500, + 'order' => ['Messages.created' => 'DESC'], + ]); + + $items = []; + foreach ($potatoes->items() as $message) { + $sent = $message->sender_user_id === $userId; + $items[] = [ + 'id' => $message->id, + 'amount' => $message->amount, + 'direction' => $sent ? 'sent' : 'received', + 'user_id' => $sent ? $message->receiver_user_id : $message->sender_user_id, + 'created' => $message->created, + ]; + } + + return $this->response + ->withStatus(200) + ->withType('json') + ->withStringBody(json_encode([ + 'potatoes' => $items, + 'pagination' => [ + 'page' => $potatoes->currentPage(), + 'per_page' => $potatoes->perPage(), + 'total' => $potatoes->totalCount(), + 'total_pages' => $potatoes->pageCount(), + 'has_next' => $potatoes->hasNextPage(), + ], + ])); + } +} From 18cfc12d6db34f15dc1ab42f010e2bcf04a51d7c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 10 Jul 2026 13:22:06 +0200 Subject: [PATCH 2/3] fix(api): add id tiebreaker to potato history ordering Messages.created is a datetime without fractional seconds, so multi-recipient potato awards create several rows with identical timestamps. Ordering only by created made page boundaries non-deterministic between requests, which could produce duplicate or missing rows for consumers walking all pages. Flagged by Cursor Bugbot on PR #449. --- src/Controller/Api/PotatoesController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Controller/Api/PotatoesController.php b/src/Controller/Api/PotatoesController.php index bf389905..19ff109f 100644 --- a/src/Controller/Api/PotatoesController.php +++ b/src/Controller/Api/PotatoesController.php @@ -52,7 +52,10 @@ public function list(): Response 'allowedParameters' => ['page'], 'limit' => min(max((int)$this->request->getQuery('per_page', '100'), 1), 500), 'maxLimit' => 500, - 'order' => ['Messages.created' => 'DESC'], + 'order' => [ + 'Messages.created' => 'DESC', + 'Messages.id' => 'DESC', + ], ]); $items = []; From 619178561acf3d55d62f589a23af291b6d4539d5 Mon Sep 17 00:00:00 2001 From: Michael Hoffmann Date: Tue, 14 Jul 2026 00:26:40 +0200 Subject: [PATCH 3/3] wip --- src/Controller/Api/PotatoesController.php | 83 +++++++---------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/src/Controller/Api/PotatoesController.php b/src/Controller/Api/PotatoesController.php index 19ff109f..0b5488a3 100644 --- a/src/Controller/Api/PotatoesController.php +++ b/src/Controller/Api/PotatoesController.php @@ -11,77 +11,40 @@ class PotatoesController extends ApiController { /** - * Paginated list of individual potato transactions (sent and received) - * for the authenticated user. No message content, just the essentials - * for building an over-time history in external systems. - * * @return \Cake\Http\Response */ public function list(): Response { - $userId = $this->Authentication->getIdentityData('id'); - $messagesTable = $this->fetchTable('Messages'); - $query = $messagesTable->find() - ->select([ - 'Messages.id', - 'Messages.sender_user_id', - 'Messages.receiver_user_id', - 'Messages.amount', - 'Messages.created', - ]); - - $direction = $this->request->getQuery('direction'); - switch ($direction) { - case 'sent': - $query->where(['Messages.sender_user_id' => $userId]); - break; - case 'received': - $query->where(['Messages.receiver_user_id' => $userId]); - break; - default: - $query->where([ - 'OR' => [ - 'Messages.sender_user_id' => $userId, - 'Messages.receiver_user_id' => $userId, - ], - ]); - } - $potatoes = $this->paginate($query, [ - 'allowedParameters' => ['page'], - 'limit' => min(max((int)$this->request->getQuery('per_page', '100'), 1), 500), - 'maxLimit' => 500, - 'order' => [ - 'Messages.created' => 'DESC', - 'Messages.id' => 'DESC', - ], - ]); - - $items = []; - foreach ($potatoes->items() as $message) { - $sent = $message->sender_user_id === $userId; - $items[] = [ - 'id' => $message->id, - 'amount' => $message->amount, - 'direction' => $sent ? 'sent' : 'received', - 'user_id' => $sent ? $message->receiver_user_id : $message->sender_user_id, - 'created' => $message->created, - ]; - } + $sent = $messagesTable->find() + ->select([ + 'amount' => 'Messages.amount', + 'recipient' => 'Messages.receiver_user_id', + 'created' => 'Messages.created', + ]) + ->where(['Messages.sender_user_id' => $this->Authentication->getIdentity()->getIdentifier()]) + ->orderBy(['Messages.created' => 'DESC']) + ->disableHydration() + ->all(); + + $received = $messagesTable->find() + ->select([ + 'amount' => 'Messages.amount', + 'sender' => 'Messages.sender_user_id', + 'created' => 'Messages.created', + ]) + ->where(['Messages.receiver_user_id' => $this->Authentication->getIdentity()->getIdentifier()]) + ->orderBy(['Messages.created' => 'DESC']) + ->disableHydration() + ->all(); return $this->response ->withStatus(200) ->withType('json') ->withStringBody(json_encode([ - 'potatoes' => $items, - 'pagination' => [ - 'page' => $potatoes->currentPage(), - 'per_page' => $potatoes->perPage(), - 'total' => $potatoes->totalCount(), - 'total_pages' => $potatoes->pageCount(), - 'has_next' => $potatoes->hasNextPage(), - ], + 'sent' => $sent, + 'received' => $received, ])); } }