Track when a card moves between columns/swim lanes: who moved it, when,
and where from/to. Logged on every column/lane change via CardsController::move(),
surfaced in the card modal via GET /cards/{id}/history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
| @@ -7,6 +7,7 @@ namespace App\Controllers; | |||
| use App\Models\Board; | |||
| use App\Repositories\BoardColumnRepository; | |||
| use App\Repositories\BoardRepository; | |||
| use App\Repositories\CardMovementRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Repositories\SwimLaneRepository; | |||
| use App\Services\AuthService; | |||
| @@ -35,6 +36,11 @@ class BoardsController extends Controller | |||
| return new CardRepository(database()); | |||
| } | |||
| private function cardMovements(): CardMovementRepository | |||
| { | |||
| return new CardMovementRepository(database()); | |||
| } | |||
| public function index(): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| @@ -185,6 +191,7 @@ class BoardsController extends Controller | |||
| return $this->redirect('/boards'); | |||
| } | |||
| $this->cardMovements()->deleteByBoardId($board->id); | |||
| $this->cards()->deleteByBoardId($board->id); | |||
| $this->columns()->deleteByBoardId($board->id); | |||
| $this->lanes()->deleteByBoardId($board->id); | |||
| @@ -5,7 +5,11 @@ declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\Card; | |||
| use App\Models\CardMovement; | |||
| use App\Repositories\BoardColumnRepository; | |||
| use App\Repositories\CardMovementRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Repositories\SwimLaneRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| @@ -17,6 +21,21 @@ class CardsController extends Controller | |||
| return new CardRepository(database()); | |||
| } | |||
| private function cardMovements(): CardMovementRepository | |||
| { | |||
| return new CardMovementRepository(database()); | |||
| } | |||
| private function columns(): BoardColumnRepository | |||
| { | |||
| return new BoardColumnRepository(database()); | |||
| } | |||
| private function lanes(): SwimLaneRepository | |||
| { | |||
| return new SwimLaneRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -99,6 +118,12 @@ class CardsController extends Controller | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $card = $this->cards()->findById($id); | |||
| if ($card === null) { | |||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||
| } | |||
| $columnId = (int) $request->input('column_id', 0); | |||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | |||
| $position = (int) $request->input('position', 0); | |||
| @@ -107,6 +132,28 @@ class CardsController extends Controller | |||
| $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); | |||
| } | |||
| $siblings = trim((string) $request->input('sibling_ids', '')); | |||
| if ($siblings !== '') { | |||
| foreach (explode(',', $siblings) as $idx => $sibId) { | |||
| @@ -126,8 +173,23 @@ class CardsController extends Controller | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cardMovements()->deleteByCardId($id); | |||
| $this->cards()->delete($id); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function history(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $movements = array_map( | |||
| fn($m) => $m->toJsonArray(), | |||
| $this->cardMovements()->findByCardId($id) | |||
| ); | |||
| return $this->json(['ok' => true, 'movements' => $movements]); | |||
| } | |||
| } | |||
| @@ -6,6 +6,7 @@ namespace App\Controllers; | |||
| use App\Models\BoardColumn; | |||
| use App\Repositories\BoardColumnRepository; | |||
| use App\Repositories\CardMovementRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| @@ -23,6 +24,11 @@ class ColumnsController extends Controller | |||
| return new CardRepository(database()); | |||
| } | |||
| private function cardMovements(): CardMovementRepository | |||
| { | |||
| return new CardMovementRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -189,6 +195,7 @@ class ColumnsController extends Controller | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cardMovements()->deleteByColumnId($id); | |||
| $this->cards()->deleteByColumnId($id); | |||
| $this->columns()->delete($id); | |||
| @@ -5,6 +5,7 @@ declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\SwimLane; | |||
| use App\Repositories\CardMovementRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Repositories\SwimLaneRepository; | |||
| use App\Services\AuthService; | |||
| @@ -23,6 +24,11 @@ class SwimLanesController extends Controller | |||
| return new CardRepository(database()); | |||
| } | |||
| private function cardMovements(): CardMovementRepository | |||
| { | |||
| return new CardMovementRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| @@ -189,6 +195,7 @@ class SwimLanesController extends Controller | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cardMovements()->deleteBySwimLaneId($id); | |||
| $this->cards()->deleteBySwimLaneId($id); | |||
| $this->lanes()->delete($id); | |||
| @@ -0,0 +1,57 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class CardMovement | |||
| { | |||
| public int $id = 0; | |||
| public int $cardId = 0; | |||
| public ?int $fromColumnId = null; | |||
| public ?string $fromColumnName = null; | |||
| public int $toColumnId = 0; | |||
| public ?string $toColumnName = null; | |||
| public ?int $fromSwimLaneId = null; | |||
| public ?string $fromSwimLaneName = null; | |||
| public int $toSwimLaneId = 0; | |||
| public ?string $toSwimLaneName = null; | |||
| public string $movedAt = ''; | |||
| public string $movedBy = ''; | |||
| public static function fromRow(array $row): self | |||
| { | |||
| $model = new self(); | |||
| $model->id = (int) ($row['id'] ?? 0); | |||
| $model->cardId = (int) ($row['card_id'] ?? 0); | |||
| $model->fromColumnId = isset($row['from_column_id']) ? (int) $row['from_column_id'] : null; | |||
| $model->fromColumnName = $row['from_column_name'] ?? null; | |||
| $model->toColumnId = (int) ($row['to_column_id'] ?? 0); | |||
| $model->toColumnName = $row['to_column_name'] ?? null; | |||
| $model->fromSwimLaneId = isset($row['from_swim_lane_id']) ? (int) $row['from_swim_lane_id'] : null; | |||
| $model->fromSwimLaneName = $row['from_swim_lane_name'] ?? null; | |||
| $model->toSwimLaneId = (int) ($row['to_swim_lane_id'] ?? 0); | |||
| $model->toSwimLaneName = $row['to_swim_lane_name'] ?? null; | |||
| $model->movedAt = (string) ($row['moved_at'] ?? ''); | |||
| $model->movedBy = (string) ($row['moved_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| public function toJsonArray(): array | |||
| { | |||
| return [ | |||
| 'id' => $this->id, | |||
| 'from_column_id' => $this->fromColumnId, | |||
| 'from_column_name' => $this->fromColumnName, | |||
| 'to_column_id' => $this->toColumnId, | |||
| 'to_column_name' => $this->toColumnName, | |||
| 'from_swim_lane_id' => $this->fromSwimLaneId, | |||
| 'from_swim_lane_name' => $this->fromSwimLaneName, | |||
| 'to_swim_lane_id' => $this->toSwimLaneId, | |||
| 'to_swim_lane_name' => $this->toSwimLaneName, | |||
| 'moved_at' => $this->movedAt, | |||
| 'moved_by' => $this->movedBy, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,85 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\CardMovement; | |||
| use Core\Repository; | |||
| class CardMovementRepository extends Repository | |||
| { | |||
| protected string $table = 'card_movements'; | |||
| public function insert(CardMovement $movement): CardMovement | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO card_movements | |||
| (card_id, from_column_id, from_column_name, to_column_id, to_column_name, | |||
| from_swim_lane_id, from_swim_lane_name, to_swim_lane_id, to_swim_lane_name, | |||
| moved_at, moved_by) | |||
| VALUES | |||
| (:card_id, :from_column_id, :from_column_name, :to_column_id, :to_column_name, | |||
| :from_swim_lane_id, :from_swim_lane_name, :to_swim_lane_id, :to_swim_lane_name, | |||
| :moved_at, :moved_by)', | |||
| [ | |||
| 'card_id' => $movement->cardId, | |||
| 'from_column_id' => $movement->fromColumnId, | |||
| 'from_column_name' => $movement->fromColumnName, | |||
| 'to_column_id' => $movement->toColumnId, | |||
| 'to_column_name' => $movement->toColumnName, | |||
| 'from_swim_lane_id' => $movement->fromSwimLaneId, | |||
| 'from_swim_lane_name' => $movement->fromSwimLaneName, | |||
| 'to_swim_lane_id' => $movement->toSwimLaneId, | |||
| 'to_swim_lane_name' => $movement->toSwimLaneName, | |||
| 'moved_at' => $movement->movedAt, | |||
| 'moved_by' => $movement->movedBy, | |||
| ] | |||
| ); | |||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||
| $movement->id = (int) ($row['id'] ?? 0); | |||
| return $movement; | |||
| } | |||
| /** @return CardMovement[] */ | |||
| public function findByCardId(int $cardId): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT * FROM card_movements WHERE card_id = :card_id ORDER BY moved_at DESC, id DESC', | |||
| ['card_id' => $cardId] | |||
| ); | |||
| return array_map(fn(array $r) => CardMovement::fromRow($r), $rows); | |||
| } | |||
| public function deleteByCardId(int $cardId): void | |||
| { | |||
| $this->database->execute('DELETE FROM card_movements WHERE card_id = :card_id', ['card_id' => $cardId]); | |||
| } | |||
| public function deleteByBoardId(int $boardId): void | |||
| { | |||
| $this->database->execute( | |||
| 'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE board_id = :board_id)', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| } | |||
| public function deleteByColumnId(int $columnId): void | |||
| { | |||
| $this->database->execute( | |||
| 'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE column_id = :column_id)', | |||
| ['column_id' => $columnId] | |||
| ); | |||
| } | |||
| public function deleteBySwimLaneId(int $swimLaneId): void | |||
| { | |||
| $this->database->execute( | |||
| 'DELETE FROM card_movements WHERE card_id IN (SELECT id FROM cards WHERE swim_lane_id = :swim_lane_id)', | |||
| ['swim_lane_id' => $swimLaneId] | |||
| ); | |||
| } | |||
| } | |||
| @@ -43,6 +43,11 @@ | |||
| </div> | |||
| <div id="card-modal-error" class="alert alert-danger d-none"></div> | |||
| <div class="mb-3 d-none" id="card-history-wrap"> | |||
| <label class="form-label">Move History</label> | |||
| <ul class="list-group list-group-flush small" id="card-history-list" style="max-height: 200px; overflow-y: auto;"></ul> | |||
| </div> | |||
| </div> | |||
| <div class="modal-footer"> | |||
| <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | |||
| @@ -0,0 +1,37 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS card_movements ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| card_id INTEGER NOT NULL, | |||
| from_column_id INTEGER, | |||
| from_column_name VARCHAR(255), | |||
| to_column_id INTEGER NOT NULL, | |||
| to_column_name VARCHAR(255), | |||
| from_swim_lane_id INTEGER, | |||
| from_swim_lane_name VARCHAR(255), | |||
| to_swim_lane_id INTEGER NOT NULL, | |||
| to_swim_lane_name VARCHAR(255), | |||
| moved_at DATETIME NOT NULL, | |||
| moved_by VARCHAR(255), | |||
| FOREIGN KEY (card_id) REFERENCES cards(id) | |||
| )' | |||
| ); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_card_movements_card_id ON card_movements (card_id)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS card_movements'); | |||
| } | |||
| }; | |||
| @@ -20,6 +20,9 @@ | |||
| var notesEl = document.getElementById('card-notes'); | |||
| var fullNoteEl = document.getElementById('card-full-note'); | |||
| var historyWrap = document.getElementById('card-history-wrap'); | |||
| var historyList = document.getElementById('card-history-list'); | |||
| var boardId = KANBAN.boardId; | |||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||
| @@ -42,6 +45,42 @@ | |||
| errEl.classList.add('d-none'); | |||
| } | |||
| function escapeHtml(s) { | |||
| return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { | |||
| return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; | |||
| }); | |||
| } | |||
| function formatMovedAt(s) { | |||
| if (!s) return ''; | |||
| var d = new Date(String(s).replace(' ', 'T')); | |||
| if (isNaN(d.getTime())) return s; | |||
| return d.toLocaleString(); | |||
| } | |||
| function loadHistory(id) { | |||
| historyList.innerHTML = ''; | |||
| fetch('/cards/' + id + '/history') | |||
| .then(function (r) { return r.json(); }) | |||
| .then(function (res) { | |||
| if (!res.ok || !res.movements || !res.movements.length) { | |||
| historyWrap.classList.add('d-none'); | |||
| return; | |||
| } | |||
| historyWrap.classList.remove('d-none'); | |||
| res.movements.forEach(function (m) { | |||
| var li = document.createElement('li'); | |||
| li.className = 'list-group-item px-0 py-1'; | |||
| li.innerHTML = | |||
| '<strong>' + escapeHtml(m.to_column_name || 'Unknown column') + '</strong>' + | |||
| ' — ' + escapeHtml(m.moved_by || 'Unknown user') + | |||
| ' <span class="text-muted">(' + escapeHtml(formatMovedAt(m.moved_at)) + ')</span>'; | |||
| historyList.appendChild(li); | |||
| }); | |||
| }) | |||
| .catch(function () { historyWrap.classList.add('d-none'); }); | |||
| } | |||
| /* ── Open for create ──────────────────────────────────────── */ | |||
| function openCreate(bId, colId, laneId) { | |||
| titleEl.textContent = 'Add Card'; | |||
| @@ -56,6 +95,8 @@ | |||
| notesEl.value = ''; | |||
| fullNoteEl.value = ''; | |||
| btnDelete.classList.add('d-none'); | |||
| historyWrap.classList.add('d-none'); | |||
| historyList.innerHTML = ''; | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| @@ -75,6 +116,7 @@ | |||
| notesEl.value = notes || ''; | |||
| fullNoteEl.value = fullNote || ''; | |||
| btnDelete.classList.remove('d-none'); | |||
| loadHistory(id); | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| @@ -24,6 +24,7 @@ $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); | |||
| // Cards (JSON API) | |||
| $router->post('/cards', [CardsController::class, 'store']); | |||
| $router->get('/cards/{id}/history', [CardsController::class, 'history']); | |||
| $router->post('/cards/{id}/move', [CardsController::class, 'move']); | |||
| $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); | |||
| $router->post('/cards/{id}', [CardsController::class, 'update']); | |||
Powered by TurnKey Linux.