diff --git a/README.md b/README.md index 5e37f9e..c8c0457 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,11 @@ Because I like seeing the numbers. Language | Files | Blank | Comment | Code -------------|--------:|---------:|--------:|---------: -TypeScript | 61 | 853 | 38 | 3901 -PHP | 18 | 599 | 26 | 1920 +TypeScript | 61 | 855 | 38 | 3910 +PHP | 19 | 624 | 27 | 1997 HTML | 19 | 151 | 0 | 1396 SASS | 14 | 263 | 12 | 1181 -__SUM:__ | __112__ | __1866__ | __76__ | __8398__ +__SUM:__ | __113__ | __1893__ | __77__ | __8484__ Command: `cloc --exclude-dir=vendor --exclude-ext=json src/` @@ -164,8 +164,8 @@ Command: `cloc --exclude-dir=vendor --exclude-ext=json src/` Language | Files | Blank | Comment | Code -------------|-------:|---------:|--------:|---------: JavaScript | 45 | 624 | 53 | 2478 -PHP | 10 | 721 | 19 | 2128 -__SUM:__ | __55__ | __1345__ | __72__ | __4606__ +PHP | 11 | 743 | 16 | 2205 +__SUM:__ | __56__ | __1367__ | __69__ | __4683__ Command: `cloc --exclude-ext=xml test/` diff --git a/src/api/app-setup.php b/src/api/app-setup.php index 062a5ca..3f71b38 100644 --- a/src/api/app-setup.php +++ b/src/api/app-setup.php @@ -32,6 +32,17 @@ $container['errorHandler'] = function ($c) { }; }; +$container['phpErrorHandler'] = function ($c) { + return function ($request, $response, $exception) use ($c) { + $c['logger']->addError('Server error', $exception->getTrace()); + + return $c['response']->withStatus(500) + ->withHeader('Content-Type', 'application/json') + ->write('{ message: "Internal Server Error", error: "' . + $exception->getMessage() . '" }'); + }; +}; + // Routes ending in '/' use route without '/' $app->add(function($request, $response, $next) { $uri = $request->getUri(); diff --git a/src/api/controllers/Activity.php b/src/api/controllers/Activity.php new file mode 100644 index 0000000..d39654c --- /dev/null +++ b/src/api/controllers/Activity.php @@ -0,0 +1,90 @@ +secureRoute($request, $response, + SecurityLevel::BOARD_ADMIN); + if ($status !== 200) { + return $this->jsonResponse($response, $status); + } + + $activity = []; + + // TODO: More activity types + if ($args['type'] === 'task') { + if (!$this->checkBoardAccess($this->getBoardId((int)$args['id']), + $request)) { + return $this->jsonResponse($response, 403); + } + + $activity = $this->getTaskActivity((int)$args['id']); + } + + $this->apiJson->setSuccess(); + $this->apiJson->addData($activity); + + return $this->jsonResponse($response); + } + + private function getBoardId($taskId) { + $task = R::load('task', $taskId); + $column = R::load('column', $task->column_id); + + return $column->board_id; + } + + private function sortLogs($a, $b) { + if ($a->timestamp === $b->timestamp) { + return 0; // @codeCoverageIgnore + } + + return $a->timestamp < $b->timestamp ? -1 : 1; + } + + private function getTaskActivity($taskId) { + $task = R::load('task', $taskId); + $logs = []; + $commentIds = []; + $attachmentIds = []; + + foreach ($task->ownComment as $comment) { + $commentIds[] = (int)$comment->id; + } + + foreach ($task->ownAttachment as $attachment) { + $attachmentIds[] = (int)$attachment->id; + } + + $taskActivity = R::find('activity', + 'item_type="task" AND item_id=?', + [$taskId]); + $this->addLogItems($logs, $taskActivity); + + $commentActivity = + R::find('activity', 'item_type="comment" AND '. + 'item_id IN(' . R::genSlots($commentIds) . ')', + $commentIds); + $this->addLogItems($logs, $commentActivity); + + $attachmentActivity = + R::find('activity', 'item_type="attachment" AND '. + 'item_id IN(' . R::genSlots($attachmentIds) . ')', + $attachmentIds); + $this->addLogItems($logs, $attachmentActivity); + + usort($logs, array("Activity", "sortLogs")); + + return $logs; + } + + private function addLogItems(&$logs, $items) { + foreach ($items as $logItem) { + $logs[] = (object)array('text'=>$logItem->log_text, + 'timestamp'=>$logItem->timestamp); + } + } + +} + diff --git a/src/api/index.php b/src/api/index.php index 9177040..ac6478c 100644 --- a/src/api/index.php +++ b/src/api/index.php @@ -49,6 +49,8 @@ $app->post('/users/{id}/opts', 'Users:updateUserOptions'); // User (limited to s $app->post('/users/{id}/cols', 'Users:toggleCollapsed'); // User (limited to self) $app->delete('/users/{id}', 'Users:removeUser'); // Admin +$app->get('/activity[/{type}[/{id}]]', 'Activity:getActivity'); // BoardAdmin (with board access) + $app->post('/login', 'Auth:login'); // Unsecured (creates JWT) $app->post('/logout', 'Auth:logout'); // Unsecured (clears JWT) $app->post('/authenticate', 'Auth:authenticate'); // Unsecured (checks JWT) diff --git a/src/app/board/board.service.ts b/src/app/board/board.service.ts index 8fc2108..30f812d 100644 --- a/src/app/board/board.service.ts +++ b/src/app/board/board.service.ts @@ -73,6 +73,12 @@ export class BoardService { .catch(this.errorHandler); } + getTaskActivity(taskId: number): Observable { + return this.http.get('api/activity/task/' + taskId) + .map(this.toApiResponse) + .catch(this.errorHandler); + } + updateComment(comment: Comment): Observable { return this.http.post('api/comments/' + comment.id, comment) .map(this.toApiResponse) diff --git a/test/api/controllers/ActivityTest.php b/test/api/controllers/ActivityTest.php new file mode 100644 index 0000000..0f46c00 --- /dev/null +++ b/test/api/controllers/ActivityTest.php @@ -0,0 +1,99 @@ +activity = new Activity(new ContainerMock()); + } + + public function testGetActivityInvalid() { + $request = new RequestMock(); + $request->hasHeader = false; + + $args = []; + $args['type'] = 'task'; + $args['id'] = 1; + + $actual = $this->activity->getActivity($request, + new ResponseMock(), $args); + $this->assertEquals('error', $actual->alerts[0]['type']); + } + + public function testGetActivityForbidden() { + $this->setupTaskActivity(); + + $args = []; + $args['type'] = 'task'; + $args['id'] = 1; + + DataMock::CreateBoardAdminUser(); + + $request = new RequestMock(); + $request->header = [DataMock::GetJwt(2)]; + + $actual = $this->activity->getActivity($request, + new ResponseMock(), $args); + $this->assertEquals('Access restricted.', $actual->alerts[0]['text']); + } + + public function testGetActivityForTask() { + $this->setupTaskActivity(); + + $request = new RequestMock(); + $request->header = [DataMock::GetJwt()]; + + $args = []; + $args['type'] = 'task'; + $args['id'] = 1; + + $actual = $this->activity->getActivity($request, + new ResponseMock(), $args); + $this->assertEquals('success', $actual->status); + $this->assertEquals(3, count($actual->data[1])); + } + + private function setupTaskActivity() { + $task = R::dispense('task'); + $comment = R::dispense('comment'); + $attachment = R::dispense('attachment'); + $task->ownComment[] = $comment; + $task->ownAttachment[] = $attachment; + R::store($task); + + + $activity = R::dispense('activity'); + $activity->item_type = 'task'; + $activity->item_id = 1; + $activity->log_text = 'test change'; + $activity->timestamp = time(); + R::store($activity); + + $activity = R::dispense('activity'); + $activity->item_type = 'task'; + $activity->item_id = 1; + $activity->log_text = 'test change'; + $activity->timestamp = time(); + R::store($activity); + + $activity = R::dispense('activity'); + $activity->item_type = 'task'; + $activity->item_id = 1; + $activity->log_text = 'test change'; + $activity->timestamp = time() + 10; + R::store($activity); + } +} + diff --git a/test/api/controllers/TasksTest.php b/test/api/controllers/TasksTest.php index 126c5ff..846c387 100644 --- a/test/api/controllers/TasksTest.php +++ b/test/api/controllers/TasksTest.php @@ -169,9 +169,6 @@ class TasksTest extends PHPUnit_Framework_TestCase { $this->assertEquals('updated', $response->data[1][0]['title']); } - /** - * @group single - */ public function testUpdateTaskWithActions() { $this->addActions(); $this->createTask();