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\Models\Board; | ||||
| use App\Repositories\BoardColumnRepository; | use App\Repositories\BoardColumnRepository; | ||||
| use App\Repositories\BoardRepository; | use App\Repositories\BoardRepository; | ||||
| use App\Repositories\CardMovementRepository; | |||||
| use App\Repositories\CardRepository; | use App\Repositories\CardRepository; | ||||
| use App\Repositories\SwimLaneRepository; | use App\Repositories\SwimLaneRepository; | ||||
| use App\Services\AuthService; | use App\Services\AuthService; | ||||
| @@ -35,6 +36,11 @@ class BoardsController extends Controller | |||||
| return new CardRepository(database()); | return new CardRepository(database()); | ||||
| } | } | ||||
| private function cardMovements(): CardMovementRepository | |||||
| { | |||||
| return new CardMovementRepository(database()); | |||||
| } | |||||
| public function index(): mixed | public function index(): mixed | ||||
| { | { | ||||
| if ($guard = AuthService::requireLogin()) { | if ($guard = AuthService::requireLogin()) { | ||||
| @@ -185,6 +191,7 @@ class BoardsController extends Controller | |||||
| return $this->redirect('/boards'); | return $this->redirect('/boards'); | ||||
| } | } | ||||
| $this->cardMovements()->deleteByBoardId($board->id); | |||||
| $this->cards()->deleteByBoardId($board->id); | $this->cards()->deleteByBoardId($board->id); | ||||
| $this->columns()->deleteByBoardId($board->id); | $this->columns()->deleteByBoardId($board->id); | ||||
| $this->lanes()->deleteByBoardId($board->id); | $this->lanes()->deleteByBoardId($board->id); | ||||
| @@ -5,7 +5,11 @@ declare(strict_types=1); | |||||
| namespace App\Controllers; | namespace App\Controllers; | ||||
| use App\Models\Card; | use App\Models\Card; | ||||
| use App\Models\CardMovement; | |||||
| use App\Repositories\BoardColumnRepository; | |||||
| use App\Repositories\CardMovementRepository; | |||||
| use App\Repositories\CardRepository; | use App\Repositories\CardRepository; | ||||
| use App\Repositories\SwimLaneRepository; | |||||
| use App\Services\AuthService; | use App\Services\AuthService; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| use Core\Request; | use Core\Request; | ||||
| @@ -17,6 +21,21 @@ class CardsController extends Controller | |||||
| return new CardRepository(database()); | 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 | public function store(Request $request): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -99,6 +118,12 @@ class CardsController extends Controller | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | 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); | $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); | ||||
| @@ -107,6 +132,28 @@ class CardsController extends Controller | |||||
| $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); | $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', '')); | $siblings = trim((string) $request->input('sibling_ids', '')); | ||||
| if ($siblings !== '') { | if ($siblings !== '') { | ||||
| foreach (explode(',', $siblings) as $idx => $sibId) { | foreach (explode(',', $siblings) as $idx => $sibId) { | ||||
| @@ -126,8 +173,23 @@ class CardsController extends Controller | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | ||||
| } | } | ||||
| $this->cardMovements()->deleteByCardId($id); | |||||
| $this->cards()->delete($id); | $this->cards()->delete($id); | ||||
| return $this->json(['ok' => true]); | 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\Models\BoardColumn; | ||||
| use App\Repositories\BoardColumnRepository; | use App\Repositories\BoardColumnRepository; | ||||
| use App\Repositories\CardMovementRepository; | |||||
| use App\Repositories\CardRepository; | use App\Repositories\CardRepository; | ||||
| use App\Services\AuthService; | use App\Services\AuthService; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| @@ -23,6 +24,11 @@ class ColumnsController extends Controller | |||||
| return new CardRepository(database()); | return new CardRepository(database()); | ||||
| } | } | ||||
| private function cardMovements(): CardMovementRepository | |||||
| { | |||||
| return new CardMovementRepository(database()); | |||||
| } | |||||
| public function store(Request $request): mixed | public function store(Request $request): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -189,6 +195,7 @@ class ColumnsController extends Controller | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | ||||
| } | } | ||||
| $this->cardMovements()->deleteByColumnId($id); | |||||
| $this->cards()->deleteByColumnId($id); | $this->cards()->deleteByColumnId($id); | ||||
| $this->columns()->delete($id); | $this->columns()->delete($id); | ||||
| @@ -5,6 +5,7 @@ declare(strict_types=1); | |||||
| namespace App\Controllers; | namespace App\Controllers; | ||||
| use App\Models\SwimLane; | use App\Models\SwimLane; | ||||
| use App\Repositories\CardMovementRepository; | |||||
| use App\Repositories\CardRepository; | use App\Repositories\CardRepository; | ||||
| use App\Repositories\SwimLaneRepository; | use App\Repositories\SwimLaneRepository; | ||||
| use App\Services\AuthService; | use App\Services\AuthService; | ||||
| @@ -23,6 +24,11 @@ class SwimLanesController extends Controller | |||||
| return new CardRepository(database()); | return new CardRepository(database()); | ||||
| } | } | ||||
| private function cardMovements(): CardMovementRepository | |||||
| { | |||||
| return new CardMovementRepository(database()); | |||||
| } | |||||
| public function store(Request $request): mixed | public function store(Request $request): mixed | ||||
| { | { | ||||
| if (!AuthService::isLoggedIn()) { | if (!AuthService::isLoggedIn()) { | ||||
| @@ -189,6 +195,7 @@ class SwimLanesController extends Controller | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | ||||
| } | } | ||||
| $this->cardMovements()->deleteBySwimLaneId($id); | |||||
| $this->cards()->deleteBySwimLaneId($id); | $this->cards()->deleteBySwimLaneId($id); | ||||
| $this->lanes()->delete($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> | ||||
| <div id="card-modal-error" class="alert alert-danger d-none"></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> | ||||
| <div class="modal-footer"> | <div class="modal-footer"> | ||||
| <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | <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 notesEl = document.getElementById('card-notes'); | ||||
| var fullNoteEl = document.getElementById('card-full-note'); | 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; | var boardId = KANBAN.boardId; | ||||
| /* ── Helpers ─────────────────────────────────────────────── */ | /* ── Helpers ─────────────────────────────────────────────── */ | ||||
| @@ -42,6 +45,42 @@ | |||||
| errEl.classList.add('d-none'); | 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 ──────────────────────────────────────── */ | /* ── Open for create ──────────────────────────────────────── */ | ||||
| function openCreate(bId, colId, laneId) { | function openCreate(bId, colId, laneId) { | ||||
| titleEl.textContent = 'Add Card'; | titleEl.textContent = 'Add Card'; | ||||
| @@ -56,6 +95,8 @@ | |||||
| notesEl.value = ''; | notesEl.value = ''; | ||||
| fullNoteEl.value = ''; | fullNoteEl.value = ''; | ||||
| btnDelete.classList.add('d-none'); | btnDelete.classList.add('d-none'); | ||||
| historyWrap.classList.add('d-none'); | |||||
| historyList.innerHTML = ''; | |||||
| clearError(); | clearError(); | ||||
| bsModal.show(); | bsModal.show(); | ||||
| jobNumEl.focus(); | jobNumEl.focus(); | ||||
| @@ -75,6 +116,7 @@ | |||||
| notesEl.value = notes || ''; | notesEl.value = notes || ''; | ||||
| fullNoteEl.value = fullNote || ''; | fullNoteEl.value = fullNote || ''; | ||||
| btnDelete.classList.remove('d-none'); | btnDelete.classList.remove('d-none'); | ||||
| loadHistory(id); | |||||
| clearError(); | clearError(); | ||||
| bsModal.show(); | bsModal.show(); | ||||
| jobNumEl.focus(); | jobNumEl.focus(); | ||||
| @@ -24,6 +24,7 @@ $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); | |||||
| // Cards (JSON API) | // Cards (JSON API) | ||||
| $router->post('/cards', [CardsController::class, 'store']); | $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}/move', [CardsController::class, 'move']); | ||||
| $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); | $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); | ||||
| $router->post('/cards/{id}', [CardsController::class, 'update']); | $router->post('/cards/{id}', [CardsController::class, 'update']); | ||||
Powered by TurnKey Linux.