diff --git a/src/Client/Bc4Client.php b/src/Client/Bc4Client.php index cc6de2a..a79c067 100644 --- a/src/Client/Bc4Client.php +++ b/src/Client/Bc4Client.php @@ -7,6 +7,9 @@ use Dmstr\Bc4Client\Authentication\AuthenticationInterface; use Dmstr\Bc4Client\Exception\RequestException; +use Dmstr\Bc4Client\Resource\CardsResource; +use Dmstr\Bc4Client\Resource\CardTablesResource; +use Dmstr\Bc4Client\Resource\ColumnsResource; use Dmstr\Bc4Client\Resource\PeopleResource; use Dmstr\Bc4Client\Resource\ProjectsResource; use Dmstr\Bc4Client\Resource\TodoSetsResource; @@ -38,6 +41,9 @@ class Bc4Client private ?TodolistsResource $todolists = null; private ?TodosResource $todos = null; private ?PeopleResource $people = null; + private ?CardTablesResource $cardTables = null; + private ?ColumnsResource $columns = null; + private ?CardsResource $cards = null; public function __construct( private readonly string $accountId, @@ -82,6 +88,21 @@ public function people(): PeopleResource return $this->people ??= new PeopleResource($this); } + public function cardTables(): CardTablesResource + { + return $this->cardTables ??= new CardTablesResource($this); + } + + public function columns(): ColumnsResource + { + return $this->columns ??= new ColumnsResource($this); + } + + public function cards(): CardsResource + { + return $this->cards ??= new CardsResource($this); + } + /** * Single GET-or-write call to the BC4 API with full retry/refresh handling. * diff --git a/src/Resource/CardTablesResource.php b/src/Resource/CardTablesResource.php new file mode 100644 index 0000000..a612086 --- /dev/null +++ b/src/Resource/CardTablesResource.php @@ -0,0 +1,74 @@ +|null + */ + public function findInProject(int|string $projectId): ?array + { + $project = $this->client->projects()->get($projectId); + if ($project === null) { + return null; + } + + foreach ($project['dock'] ?? [] as $tool) { + if (($tool['name'] ?? null) === 'kanban_board' && ($tool['enabled'] ?? false)) { + return $tool; + } + } + + return null; + } + + /** + * Load a single card table. Its JSON embeds the `lists` array (= columns). + * + * @return array|null + */ + public function get(int|string $projectId, int|string $cardTableId): ?array + { + try { + return $this->client->request( + 'GET', + sprintf('/buckets/%s/card_tables/%s.json', $projectId, $cardTableId), + ); + } catch (RequestException $e) { + if ($e->getStatusCode() === 404) { + return null; + } + throw $e; + } + } + + /** + * Resolve the project's card table via its dock and load it. Returns null + * when the project has no enabled kanban_board tool. + * + * @return array|null + */ + public function getInProject(int|string $projectId): ?array + { + $tool = $this->findInProject($projectId); + if ($tool === null || !isset($tool['id'])) { + return null; + } + + return $this->get($projectId, $tool['id']); + } +} diff --git a/src/Resource/CardsResource.php b/src/Resource/CardsResource.php new file mode 100644 index 0000000..e41505b --- /dev/null +++ b/src/Resource/CardsResource.php @@ -0,0 +1,107 @@ +> + */ + public function getInColumn(int|string $projectId, int|string $columnId): array + { + return $this->client->paginate( + sprintf('/buckets/%s/card_tables/lists/%s/cards.json', $projectId, $columnId), + ); + } + + /** + * Walk a project's card table (all columns) and return every card enriched + * with its card table and column. Returns an empty array when the project + * has no enabled card table. + * + * @return array> + */ + public function getAllInProject(int|string $projectId): array + { + $table = $this->client->cardTables()->getInProject($projectId); + if ($table === null) { + return []; + } + + $tableRef = ['id' => $table['id'] ?? null, 'title' => $table['title'] ?? null]; + $cards = []; + + foreach ($table['lists'] ?? [] as $column) { + if (!isset($column['id'])) { + continue; + } + $columnRef = [ + 'id' => $column['id'], + 'title' => $column['title'] ?? null, + 'type' => $column['type'] ?? null, + ]; + foreach ($this->getInColumn($projectId, $column['id']) as $card) { + $card['card_table'] = $tableRef; + $card['column'] = $columnRef; + $cards[] = $card; + } + } + + return $cards; + } + + /** + * Load a single card, enriched with its column and card table (resolved via + * the card's parent column) so single-record sync matches the scan output. + * + * @return array|null + */ + public function get(int|string $projectId, int|string $cardId): ?array + { + try { + $card = $this->client->request( + 'GET', + sprintf('/buckets/%s/card_tables/cards/%s.json', $projectId, $cardId), + ); + } catch (RequestException $e) { + if ($e->getStatusCode() === 404) { + return null; + } + throw $e; + } + + // A card's `parent` is its column; the column's `parent` is the card + // table. Resolve both so the enrichment matches getAllInProject(). + $columnId = $card['parent']['id'] ?? null; + if ($columnId !== null) { + $column = $this->client->columns()->get($projectId, $columnId); + if ($column !== null) { + $card['column'] = [ + 'id' => $column['id'] ?? $columnId, + 'title' => $column['title'] ?? null, + 'type' => $column['type'] ?? null, + ]; + if (isset($column['parent']['id'])) { + $card['card_table'] = [ + 'id' => $column['parent']['id'], + 'title' => $column['parent']['title'] ?? null, + ]; + } + } + } + + return $card; + } +} diff --git a/src/Resource/ColumnsResource.php b/src/Resource/ColumnsResource.php new file mode 100644 index 0000000..fd1a485 --- /dev/null +++ b/src/Resource/ColumnsResource.php @@ -0,0 +1,45 @@ +> + */ + public function getInCardTable(int|string $projectId, int|string $cardTableId): array + { + $table = $this->client->cardTables()->get($projectId, $cardTableId); + + return is_array($table['lists'] ?? null) ? $table['lists'] : []; + } + + /** + * @return array|null + */ + public function get(int|string $projectId, int|string $columnId): ?array + { + try { + return $this->client->request( + 'GET', + sprintf('/buckets/%s/card_tables/columns/%s.json', $projectId, $columnId), + ); + } catch (RequestException $e) { + if ($e->getStatusCode() === 404) { + return null; + } + throw $e; + } + } +} diff --git a/tests/Resource/CardsResourceTest.php b/tests/Resource/CardsResourceTest.php new file mode 100644 index 0000000..26e9f29 --- /dev/null +++ b/tests/Resource/CardsResourceTest.php @@ -0,0 +1,136 @@ + 47064467, + 'dock' => [ + ['id' => 1, 'name' => 'message_board', 'enabled' => true], + ['id' => 9833339747, 'name' => 'kanban_board', 'enabled' => true, 'title' => 'Card Table'], + ], + ]; + $cardTable = [ + 'id' => 9833339747, + 'title' => 'Card Table', + 'lists' => [ + ['id' => 9833339752, 'title' => 'Triage', 'type' => 'Kanban::Triage'], + ['id' => 9833339809, 'title' => 'Done', 'type' => 'Kanban::DoneColumn'], + ], + ]; + $triageCards = [ + ['id' => 111, 'type' => 'Kanban::Card', 'title' => 'A'], + ['id' => 112, 'type' => 'Kanban::Card', 'title' => 'B'], + ]; + $doneCards = [ + ['id' => 222, 'type' => 'Kanban::Card', 'title' => 'C', 'completed' => true], + ]; + + $http = new MockHttpClient([ + new MockResponse((string) json_encode($project), ['http_code' => 200]), + new MockResponse((string) json_encode($cardTable), ['http_code' => 200]), + new MockResponse((string) json_encode($triageCards), ['http_code' => 200]), + new MockResponse((string) json_encode($doneCards), ['http_code' => 200]), + ]); + + $cards = (new Bc4Client(self::ACCOUNT, $this->makeAuthStub(), $http)) + ->cards()->getAllInProject(47064467); + + $this->assertCount(3, $cards); + $this->assertSame([111, 112, 222], array_column($cards, 'id')); + + // Every card is enriched with the card table (the parent/group). + foreach ($cards as $card) { + $this->assertSame(9833339747, $card['card_table']['id']); + $this->assertSame('Card Table', $card['card_table']['title']); + } + + // The column reflects the iterated list, not the card's own payload. + $this->assertSame('Triage', $cards[0]['column']['title']); + $this->assertSame('Kanban::Triage', $cards[0]['column']['type']); + $this->assertSame('Done', $cards[2]['column']['title']); + $this->assertSame('Kanban::DoneColumn', $cards[2]['column']['type']); + + // 1 project + 1 card table + 2 column card pages. + $this->assertSame(4, $http->getRequestsCount()); + } + + public function testGetAllInProjectReturnsEmptyWhenNoKanbanBoard(): void + { + $project = ['id' => 1, 'dock' => [['id' => 2, 'name' => 'todoset', 'enabled' => true]]]; + $http = new MockHttpClient([new MockResponse((string) json_encode($project), ['http_code' => 200])]); + + $cards = (new Bc4Client(self::ACCOUNT, $this->makeAuthStub(), $http)) + ->cards()->getAllInProject(1); + + $this->assertSame([], $cards); + $this->assertSame(1, $http->getRequestsCount(), 'No card table → no card requests'); + } + + public function testGetSingleCardResolvesColumnAndCardTable(): void + { + $card = [ + 'id' => 111, + 'type' => 'Kanban::Card', + 'title' => 'A', + 'parent' => ['id' => 9833339752, 'title' => 'Triage', 'type' => 'Kanban::Triage'], + ]; + $column = [ + 'id' => 9833339752, + 'title' => 'Triage', + 'type' => 'Kanban::Triage', + 'parent' => ['id' => 9833339747, 'title' => 'Card Table', 'type' => 'Kanban::Board'], + ]; + + $http = new MockHttpClient([ + new MockResponse((string) json_encode($card), ['http_code' => 200]), + new MockResponse((string) json_encode($column), ['http_code' => 200]), + ]); + + $result = (new Bc4Client(self::ACCOUNT, $this->makeAuthStub(), $http)) + ->cards()->get(47064467, 111); + + $this->assertNotNull($result); + $this->assertSame('Triage', $result['column']['title']); + $this->assertSame(9833339747, $result['card_table']['id']); + $this->assertSame('Card Table', $result['card_table']['title']); + } + + public function testGetSingleCardReturnsNullOn404(): void + { + $http = new MockHttpClient([new MockResponse('{"error":"not found"}', ['http_code' => 404])]); + + $result = (new Bc4Client(self::ACCOUNT, $this->makeAuthStub(), $http)) + ->cards()->get(1, 999); + + $this->assertNull($result); + } + + private function makeAuthStub(): AuthenticationInterface + { + $auth = $this->createStub(AuthenticationInterface::class); + $auth->method('requiresReauth')->willReturn(false); + $auth->method('decorate')->willReturnArgument(0); + + return $auth; + } +}