Save move history atomically with source and destination cells, display local movement times, and keep the board total current. Exclude local env files from Docker builds and remove obsolete employee route checks from the test runner.master
| @@ -2,3 +2,6 @@ | |||||
| .ai | .ai | ||||
| vendor | vendor | ||||
| database/app.sqlite | database/app.sqlite | ||||
| .env | |||||
| .env_prod | |||||
| .vs | |||||
| @@ -123,6 +123,7 @@ class BoardsController extends Controller | |||||
| 'board' => $board, | 'board' => $board, | ||||
| 'columns' => $columns, | 'columns' => $columns, | ||||
| 'lanes' => $lanes, | 'lanes' => $lanes, | ||||
| 'cardCount' => count($allCards), | |||||
| 'cardsJson' => $cardsJson, | 'cardsJson' => $cardsJson, | ||||
| ]); | ]); | ||||
| } | } | ||||
| @@ -13,6 +13,7 @@ use App\Repositories\SwimLaneRepository; | |||||
| use App\Services\AuthService; | use App\Services\AuthService; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| use Core\Request; | use Core\Request; | ||||
| use Core\Response; | |||||
| class CardsController extends Controller | class CardsController extends Controller | ||||
| { | { | ||||
| @@ -121,47 +122,72 @@ class CardsController extends Controller | |||||
| $card = $this->cards()->findById($id); | $card = $this->cards()->findById($id); | ||||
| if ($card === null) { | if ($card === null) { | ||||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| return Response::json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| } | } | ||||
| $columnId = (int) $request->input('column_id', 0); | $columnId = (int) $request->input('column_id', 0); | ||||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | $swimLaneId = (int) $request->input('swim_lane_id', 0); | ||||
| $position = (int) $request->input('position', 0); | $position = (int) $request->input('position', 0); | ||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $toColumn = $columnId > 0 ? $this->columns()->find($columnId) : null; | |||||
| $toLane = $swimLaneId > 0 ? $this->lanes()->find($swimLaneId) : null; | |||||
| $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); | |||||
| if ($columnId !== $card->columnId || $swimLaneId !== $card->swimLaneId) { | |||||
| $fromColumn = $this->columns()->find($card->columnId); | |||||
| $toColumn = $this->columns()->find($columnId); | |||||
| $fromLane = $this->lanes()->find($card->swimLaneId); | |||||
| $toLane = $this->lanes()->find($swimLaneId); | |||||
| $movement = new CardMovement(); | |||||
| $movement->cardId = $id; | |||||
| $movement->fromColumnId = $card->columnId; | |||||
| $movement->fromColumnName = $fromColumn['name'] ?? null; | |||||
| $movement->toColumnId = $columnId; | |||||
| $movement->toColumnName = $toColumn['name'] ?? null; | |||||
| $movement->fromSwimLaneId = $card->swimLaneId; | |||||
| $movement->fromSwimLaneName = $fromLane['name'] ?? null; | |||||
| $movement->toSwimLaneId = $swimLaneId; | |||||
| $movement->toSwimLaneName = $toLane['name'] ?? null; | |||||
| $movement->movedAt = $now; | |||||
| $movement->movedBy = $username; | |||||
| $this->cardMovements()->insert($movement); | |||||
| if ($toColumn === null || $toLane === null || | |||||
| (int) $toColumn['board_id'] !== $card->boardId || | |||||
| (int) $toLane['board_id'] !== $card->boardId || $position < 0) { | |||||
| return Response::json(['ok' => false, 'error' => 'Invalid destination'], 422); | |||||
| } | } | ||||
| $siblings = trim((string) $request->input('sibling_ids', '')); | |||||
| if ($siblings !== '') { | |||||
| foreach (explode(',', $siblings) as $idx => $sibId) { | |||||
| $sibId = (int) trim($sibId); | |||||
| if ($sibId > 0) { | |||||
| $this->cards()->updatePosition($sibId, $idx, $now, $username); | |||||
| $now = gmdate('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $siblings = trim((string) $request->input('sibling_ids', '')); | |||||
| try { | |||||
| database()->transaction(function () use ( | |||||
| $card, | |||||
| $id, | |||||
| $columnId, | |||||
| $swimLaneId, | |||||
| $position, | |||||
| $toColumn, | |||||
| $toLane, | |||||
| $now, | |||||
| $username, | |||||
| $siblings | |||||
| ): void { | |||||
| $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); | |||||
| if ($columnId !== $card->columnId || $swimLaneId !== $card->swimLaneId) { | |||||
| $fromColumn = $this->columns()->find($card->columnId); | |||||
| $fromLane = $this->lanes()->find($card->swimLaneId); | |||||
| $movement = new CardMovement(); | |||||
| $movement->cardId = $id; | |||||
| $movement->fromColumnId = $card->columnId; | |||||
| $movement->fromColumnName = $fromColumn['name'] ?? null; | |||||
| $movement->toColumnId = $columnId; | |||||
| $movement->toColumnName = $toColumn['name']; | |||||
| $movement->fromSwimLaneId = $card->swimLaneId; | |||||
| $movement->fromSwimLaneName = $fromLane['name'] ?? null; | |||||
| $movement->toSwimLaneId = $swimLaneId; | |||||
| $movement->toSwimLaneName = $toLane['name']; | |||||
| $movement->movedAt = $now; | |||||
| $movement->movedBy = $username; | |||||
| $this->cardMovements()->insert($movement); | |||||
| } | |||||
| if ($siblings !== '') { | |||||
| foreach (explode(',', $siblings) as $idx => $sibId) { | |||||
| $sibId = (int) trim($sibId); | |||||
| if ($sibId > 0) { | |||||
| $this->cards()->updatePosition($sibId, $idx, $now, $username); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| } | |||||
| }); | |||||
| } catch (\Throwable $e) { | |||||
| error_log('Card move failed: ' . $e->getMessage()); | |||||
| return Response::json(['ok' => false, 'error' => 'Could not move card'], 500); | |||||
| } | } | ||||
| return $this->json(['ok' => true]); | return $this->json(['ok' => true]); | ||||
| @@ -21,6 +21,7 @@ | |||||
| <i class="bi bi-arrow-left"></i> | <i class="bi bi-arrow-left"></i> | ||||
| </a> | </a> | ||||
| <span class="navbar-brand mb-0 h5 kanban-board-title"><?= e($board->name) ?></span> | <span class="navbar-brand mb-0 h5 kanban-board-title"><?= e($board->name) ?></span> | ||||
| <span id="board-card-count" class="kanban-board-card-count" aria-live="polite"><?= $cardCount ?> <?= $cardCount === 1 ? 'card' : 'cards' ?></span> | |||||
| </div> | </div> | ||||
| <div class="board-header-search"> | <div class="board-header-search"> | ||||
| <label for="job-search-input" class="visually-hidden">Search jobs</label> | <label for="job-search-input" class="visually-hidden">Search jobs</label> | ||||
| @@ -75,6 +75,18 @@ body.kanban-page .navbar-brand { | |||||
| font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); | font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); | ||||
| } | } | ||||
| .kanban-board-card-count { | |||||
| flex-shrink: 0; | |||||
| padding: 0.25rem 0.55rem; | |||||
| border: 1px solid rgba(213, 230, 255, 0.45); | |||||
| border-radius: 999px; | |||||
| background: rgba(255, 255, 255, 0.13); | |||||
| color: #f4f8ff; | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| white-space: nowrap; | |||||
| } | |||||
| body.kanban-page .navbar .btn-outline-light, | body.kanban-page .navbar .btn-outline-light, | ||||
| body.kanban-page .navbar .btn-outline-secondary { | body.kanban-page .navbar .btn-outline-secondary { | ||||
| border-color: rgba(213, 230, 255, 0.55) !important; | border-color: rgba(213, 230, 255, 0.55) !important; | ||||
| @@ -21,7 +21,7 @@ | |||||
| rafId: 0 | rafId: 0 | ||||
| }; | }; | ||||
| function post(url, data, cb) { | |||||
| function post(url, data, cb, onError) { | |||||
| var params = new URLSearchParams(); | var params = new URLSearchParams(); | ||||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | ||||
| fetch(url, { | fetch(url, { | ||||
| @@ -31,7 +31,10 @@ | |||||
| }) | }) | ||||
| .then(function (r) { return r.json(); }) | .then(function (r) { return r.json(); }) | ||||
| .then(cb) | .then(cb) | ||||
| .catch(function (e) { console.error(url, e); }); | |||||
| .catch(function (e) { | |||||
| console.error(url, e); | |||||
| if (onError) onError(); | |||||
| }); | |||||
| } | } | ||||
| function esc(s) { | function esc(s) { | ||||
| @@ -124,6 +127,13 @@ | |||||
| grid.style.gridTemplateColumns = cols; | grid.style.gridTemplateColumns = cols; | ||||
| } | } | ||||
| function refreshBoardCardCount() { | |||||
| var badge = document.getElementById('board-card-count'); | |||||
| if (!badge) return; | |||||
| var count = KANBAN.cards.length; | |||||
| badge.textContent = count + (count === 1 ? ' card' : ' cards'); | |||||
| } | |||||
| function countCardsInColumn(colId) { | function countCardsInColumn(colId) { | ||||
| var count = 0; | var count = 0; | ||||
| KANBAN.cards.forEach(function (c) { | KANBAN.cards.forEach(function (c) { | ||||
| @@ -440,8 +450,11 @@ | |||||
| position: newPos, | position: newPos, | ||||
| sibling_ids: siblings.join(',') | sibling_ids: siblings.join(',') | ||||
| }, function (res) { | }, function (res) { | ||||
| if (!res.ok) console.error('Move failed', res); | |||||
| }); | |||||
| if (!res.ok) { | |||||
| console.error('Move failed', res); | |||||
| window.location.reload(); | |||||
| } | |||||
| }, function () { window.location.reload(); }); | |||||
| } | } | ||||
| function createCellSortable(cell) { | function createCellSortable(cell) { | ||||
| @@ -542,6 +555,7 @@ | |||||
| window.KanbanBoard = { | window.KanbanBoard = { | ||||
| onCardCreated: function (card) { | onCardCreated: function (card) { | ||||
| KANBAN.cards.push(card); | KANBAN.cards.push(card); | ||||
| refreshBoardCardCount(); | |||||
| var cell = document.querySelector( | var cell = document.querySelector( | ||||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | ||||
| ); | ); | ||||
| @@ -575,6 +589,7 @@ | |||||
| var colId = card ? card.column_id : null; | var colId = card ? card.column_id : null; | ||||
| var laneId = card ? card.swim_lane_id : null; | var laneId = card ? card.swim_lane_id : null; | ||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | ||||
| refreshBoardCardCount(); | |||||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | ||||
| if (el) el.remove(); | if (el) el.remove(); | ||||
| applyCardFilter(); | applyCardFilter(); | ||||
| @@ -615,6 +630,7 @@ | |||||
| document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | ||||
| document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); | document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); | ||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | ||||
| refreshBoardCardCount(); | |||||
| delete columnShowCount[String(colId)]; | delete columnShowCount[String(colId)]; | ||||
| delete columnShowExport[String(colId)]; | delete columnShowExport[String(colId)]; | ||||
| delete columnAgeSettings[String(colId)]; | delete columnAgeSettings[String(colId)]; | ||||
| @@ -658,6 +674,7 @@ | |||||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | ||||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | ||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | ||||
| refreshBoardCardCount(); | |||||
| delete laneShowCount[String(laneId)]; | delete laneShowCount[String(laneId)]; | ||||
| delete laneShowExport[String(laneId)]; | delete laneShowExport[String(laneId)]; | ||||
| delete laneAgeSettings[String(laneId)]; | delete laneAgeSettings[String(laneId)]; | ||||
| @@ -53,7 +53,8 @@ | |||||
| function formatMovedAt(s) { | function formatMovedAt(s) { | ||||
| if (!s) return ''; | if (!s) return ''; | ||||
| var d = new Date(String(s).replace(' ', 'T')); | |||||
| // Movement timestamps are stored in UTC without a timezone suffix. | |||||
| var d = new Date(String(s).replace(' ', 'T') + 'Z'); | |||||
| if (isNaN(d.getTime())) return s; | if (isNaN(d.getTime())) return s; | ||||
| return d.toLocaleString(); | return d.toLocaleString(); | ||||
| } | } | ||||
| @@ -72,7 +73,10 @@ | |||||
| var li = document.createElement('li'); | var li = document.createElement('li'); | ||||
| li.className = 'list-group-item px-0 py-1'; | li.className = 'list-group-item px-0 py-1'; | ||||
| li.innerHTML = | li.innerHTML = | ||||
| '<strong>' + escapeHtml(m.to_column_name || 'Unknown column') + '</strong>' + | |||||
| '<strong>' + escapeHtml(m.from_column_name || 'Unknown column') + | |||||
| ' / ' + escapeHtml(m.from_swim_lane_name || 'Unknown row') + | |||||
| ' → ' + escapeHtml(m.to_column_name || 'Unknown column') + | |||||
| ' / ' + escapeHtml(m.to_swim_lane_name || 'Unknown row') + '</strong>' + | |||||
| ' — ' + escapeHtml(m.moved_by || 'Unknown user') + | ' — ' + escapeHtml(m.moved_by || 'Unknown user') + | ||||
| ' <span class="text-muted">(' + escapeHtml(formatMovedAt(m.moved_at)) + ')</span>'; | ' <span class="text-muted">(' + escapeHtml(formatMovedAt(m.moved_at)) + ')</span>'; | ||||
| historyList.appendChild(li); | historyList.appendChild(li); | ||||
| @@ -0,0 +1,111 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use App\Controllers\CardsController; | |||||
| use Core\Database; | |||||
| use Core\Request; | |||||
| spl_autoload_register(static function (string $class): void { | |||||
| $root = str_starts_with($class, 'App\\') ? 'app' : 'core'; | |||||
| $path = __DIR__ . '/../' . $root . '/' . str_replace('\\', '/', substr($class, strpos($class, '\\') + 1)) . '.php'; | |||||
| if (is_file($path)) { | |||||
| require $path; | |||||
| } | |||||
| }); | |||||
| function database(): Database | |||||
| { | |||||
| static $database = null; | |||||
| if ($database === null) { | |||||
| $database = new Database([ | |||||
| 'dsn' => 'sqlite::memory:', | |||||
| 'options' => [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], | |||||
| ]); | |||||
| $database->pdo()->exec('PRAGMA foreign_keys = ON'); | |||||
| } | |||||
| return $database; | |||||
| } | |||||
| function check(bool $condition, string $message): void | |||||
| { | |||||
| if (!$condition) { | |||||
| throw new RuntimeException($message); | |||||
| } | |||||
| } | |||||
| $db = database(); | |||||
| foreach ([ | |||||
| '20260522_000001_create_boards_table.php', | |||||
| '20260522_000002_create_board_columns_table.php', | |||||
| '20260522_000003_create_swim_lanes_table.php', | |||||
| '20260522_000004_create_cards_table.php', | |||||
| '20260615_000003_add_cell_entered_at_to_cards.php', | |||||
| '20260917_000001_create_card_movements_table.php', | |||||
| ] as $migrationFile) { | |||||
| (require __DIR__ . '/../database/migrations/' . $migrationFile)->up($db); | |||||
| } | |||||
| $db->execute("INSERT INTO boards (id, name, slug) VALUES (1, 'Main', 'main'), (2, 'Other', 'other')"); | |||||
| $db->execute("INSERT INTO board_columns (id, board_id, name, position) VALUES | |||||
| (1, 1, 'Queued', 0), (2, 1, 'Printing', 1), (3, 2, 'Other column', 0)"); | |||||
| $db->execute("INSERT INTO swim_lanes (id, board_id, name, position) VALUES | |||||
| (1, 1, 'Standard', 0), (2, 1, 'Urgent', 1), (3, 2, 'Other row', 0)"); | |||||
| $db->execute("INSERT INTO cards (id, board_id, column_id, swim_lane_id, position, cell_entered_at) | |||||
| VALUES (1, 1, 1, 1, 0, '2026-01-01 00:00:00')"); | |||||
| putenv('APP_DEBUG=0'); | |||||
| date_default_timezone_set('America/New_York'); | |||||
| $_SESSION['auth_user'] = ['preferred_username' => 'test-user']; | |||||
| $controller = new CardsController(); | |||||
| $move = static function (int $columnId, int $laneId, int $position = 0) use ($controller): Core\Response { | |||||
| return $controller->move(new Request([], [ | |||||
| 'column_id' => (string) $columnId, | |||||
| 'swim_lane_id' => (string) $laneId, | |||||
| 'position' => (string) $position, | |||||
| ], ['REQUEST_METHOD' => 'POST']), 1); | |||||
| }; | |||||
| check($move(2, 2)->status() === 200, 'Move should succeed.'); | |||||
| $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); | |||||
| check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 2, 'Card destination was not saved.'); | |||||
| $history = $db->first('SELECT * FROM card_movements WHERE card_id = 1'); | |||||
| check($history !== null, 'Movement was not recorded.'); | |||||
| check($history['from_column_name'] === 'Queued' && $history['to_column_name'] === 'Printing', 'Column history is incorrect.'); | |||||
| check($history['from_swim_lane_name'] === 'Standard' && $history['to_swim_lane_name'] === 'Urgent', 'Row history is incorrect.'); | |||||
| check($history['moved_by'] === 'test-user', 'Move user was not recorded.'); | |||||
| check(abs(strtotime($history['moved_at'] . ' UTC') - time()) < 10, 'Movement time was not saved in UTC.'); | |||||
| $response = $controller->history(1); | |||||
| $recorded = json_decode($response->content(), true, flags: JSON_THROW_ON_ERROR)['movements'][0]; | |||||
| check($recorded['to_column_name'] === 'Printing' && $recorded['to_swim_lane_name'] === 'Urgent', 'History API omitted the destination.'); | |||||
| check($move(2, 2, 1)->status() === 200, 'Reordering should succeed.'); | |||||
| check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 1, 'Reordering added a movement.'); | |||||
| check($move(2, 1)->status() === 200, 'Row-only move should succeed.'); | |||||
| check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Row-only move was not recorded.'); | |||||
| check($move(3, 1)->status() === 422, 'Cross-board column should be rejected.'); | |||||
| check($move(2, 3)->status() === 422, 'Cross-board row should be rejected.'); | |||||
| $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); | |||||
| check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Rejected move changed the card.'); | |||||
| check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Rejected move changed history.'); | |||||
| $db->execute("CREATE TRIGGER reject_movement BEFORE INSERT ON card_movements | |||||
| BEGIN SELECT RAISE(ABORT, 'forced history failure'); END"); | |||||
| $logFile = tempnam(sys_get_temp_dir(), 'card_move_'); | |||||
| $previousLog = ini_get('error_log'); | |||||
| ini_set('error_log', $logFile); | |||||
| try { | |||||
| check($move(1, 1)->status() === 500, 'History failure should reject the move.'); | |||||
| $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); | |||||
| check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Failed history write did not roll back the move.'); | |||||
| check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Failed move changed history.'); | |||||
| } finally { | |||||
| ini_set('error_log', $previousLog ?: ''); | |||||
| unlink($logFile); | |||||
| $db->execute('DROP TRIGGER reject_movement'); | |||||
| } | |||||
| echo "PASS: card moves record column and row history\n"; | |||||
| @@ -110,6 +110,7 @@ if ($response->content() !== 'Hello, Daniel') { | |||||
| exit(1); | exit(1); | ||||
| } | } | ||||
| /* Employee routes belong to the framework example and are not part of KCI Kanban. | |||||
| $employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ | $employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ | ||||
| 'REQUEST_METHOD' => 'GET', | 'REQUEST_METHOD' => 'GET', | ||||
| 'REQUEST_URI' => '/employees', | 'REQUEST_URI' => '/employees', | ||||
| @@ -141,5 +142,6 @@ if (strpos($employeeData->content(), '[') === false) { | |||||
| echo "FAIL: employee data endpoint did not return JSON array content\n"; | echo "FAIL: employee data endpoint did not return JSON array content\n"; | ||||
| exit(1); | exit(1); | ||||
| } | } | ||||
| */ | |||||
| echo "PASS: migration manager and route dispatch work\n"; | echo "PASS: migration manager and route dispatch work\n"; | ||||
Powered by TurnKey Linux.