Project configuration view: manage a project's statuses

New /projects/:id/configure view, linked from a new 'Configure' item on
the project view's Manage menu.

Backend:
- CardStatusRepository/CardStatusController gain full CRUD: create
  (appended at the end), reorder (dense positions, like card
  ordering), and delete.
- Deleting a status with cards attached is rejected with 409 and
  error.details.card_count, rather than hitting the existing FK
  RESTRICT constraint -- retrying with { reassign_to: <status id> }
  moves those cards to that status first (CardRepository::
  reassignStatus, appended after the destination's existing cards)
  and deletes in one transaction (CardStatusRepository::transaction,
  shared PDO connection across repositories).
- The last status in a project can't be deleted, since a project card
  is required to have one.
- Routes: POST/DELETE .../statuses(/:id), PUT .../statuses/order.
- 14 new CardStatusTest cases covering all of the above.

Frontend:
- ProjectConfigureView.vue: header (title, back-to-project link, the
  shared Manage menu) + a vuedraggable status list (reorder persists
  the whole new order) with a delete button per row and an add-status
  form. A row's plain delete either succeeds immediately or, on 409,
  opens a modal to choose a different status before retrying the
  delete with reassign_to.
- Extracted ProjectManageMenu.vue (the Manage dropdown + delete-project
  modal) out of ProjectView so both views share it; it now also has a
  Configure link (hidden on the configure page itself).
- ApiError gains a cardCount getter (details.card_count), mirroring
  the existing retryAfter getter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 20:31:10 +01:00
co-authored by Claude Sonnet 5
parent 21e148aa4d
commit dd7d217e8e
13 changed files with 995 additions and 126 deletions
+115 -2
View File
@@ -5,20 +5,27 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exception\ApiException;
use App\Repository\CardRepository;
use App\Repository\CardStatusRepository;
use App\Repository\ProjectRepository;
use App\Support\Validator;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Read-only listing of a project's card statuses. Statuses are seeded when the
* project is created; there is no create/update/delete yet.
* A project's card statuses: listing, and managing them from the
* configuration view (create, reorder, delete). Every project keeps at least
* one status, since a project card is required to have one (see the CHECK
* constraint on `cards`); deleting the last one is rejected.
*/
final class CardStatusController extends Controller
{
private const NAME_MAX = 100;
public function __construct(
private readonly ProjectRepository $projects,
private readonly CardStatusRepository $statuses,
private readonly CardRepository $cards,
) {
}
@@ -34,6 +41,112 @@ final class CardStatusController extends Controller
]);
}
/**
* POST /api/projects/{projectId}/statuses
*/
public function store(Request $request, Response $response, array $args): Response
{
$projectId = $this->requireOwnedProjectId($request, $args);
$validator = new Validator($this->body($request));
$name = $validator->requiredString('name', self::NAME_MAX);
$validator->assert();
$status = $this->statuses->create($projectId, $name);
return $this->json($response, ['status' => $this->present($status)], 201);
}
/**
* PUT /api/projects/{projectId}/statuses/order
*
* Body: { "status_ids": [3, 1, 2] } -- every status in the project,
* exactly once, in the desired order.
*/
public function reorder(Request $request, Response $response, array $args): Response
{
$projectId = $this->requireOwnedProjectId($request, $args);
$body = $this->body($request);
$order = $body['status_ids'] ?? null;
if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) {
throw new ApiException('status_ids must be an array of status IDs.', 422);
}
/** @var int[] $order */
if (count($order) !== count(array_unique($order))) {
throw new ApiException('status_ids must not contain duplicates.', 422);
}
$existingIds = array_column($this->statuses->allForProject($projectId), 'id');
if (array_diff($order, $existingIds) !== [] || array_diff($existingIds, $order) !== []) {
throw new ApiException('status_ids must include every status in this project, exactly once.', 422);
}
$statuses = $this->statuses->reorder($projectId, $order);
return $this->json($response, ['statuses' => array_map($this->present(...), $statuses)]);
}
/**
* DELETE /api/projects/{projectId}/statuses/{statusId}
*
* Body (optional): { "reassign_to": <status id> }. If cards still use
* this status, the request fails with 409 (details.card_count set)
* unless reassign_to names a different status in the same project --
* those cards are moved there first, then the status is deleted, in one
* transaction.
*/
public function destroy(Request $request, Response $response, array $args): Response
{
$projectId = $this->requireOwnedProjectId($request, $args);
$ownerId = $this->user($request)['id'];
$statusId = (int) $args['statusId'];
$status = $this->statuses->findInProject($statusId, $projectId);
if ($status === null) {
throw new ApiException('Status not found.', 404);
}
if ($this->statuses->countForProject($projectId) <= 1) {
throw new ApiException('A project must have at least one status.', 409);
}
$cardCount = count($this->cards->idsInColumn($ownerId, $projectId, $statusId));
$reassignTo = null;
if ($cardCount > 0) {
$body = $this->body($request);
$reassignTo = $body['reassign_to'] ?? null;
if ($reassignTo === null) {
throw new ApiException(
sprintf(
'This status still has %d card%s. Choose another status to move them to.',
$cardCount,
$cardCount === 1 ? '' : 's',
),
409,
['card_count' => $cardCount],
);
}
if (!is_int($reassignTo) || $reassignTo === $statusId) {
throw new ApiException('reassign_to must be a different status in this project.', 422);
}
if ($this->statuses->findInProject($reassignTo, $projectId) === null) {
throw new ApiException('reassign_to must be a status in this project.', 422);
}
}
$this->statuses->transaction(function () use ($ownerId, $projectId, $statusId, $reassignTo) {
if ($reassignTo !== null) {
$this->cards->reassignStatus($ownerId, $projectId, $statusId, $reassignTo);
}
$this->statuses->delete($statusId);
$this->statuses->repack($projectId);
});
return $response->withStatus(204);
}
/**
* @param array<string, string> $args
*/
+23
View File
@@ -214,6 +214,29 @@ final class CardRepository
return $this->columnCards($ownerId, $projectId, $statusId);
}
/**
* Move every card in one project status to another status in the same
* project -- used when a status is deleted out from under them. Appended
* after the destination's existing cards, so both columns stay a dense
* 0..n-1 with no repack needed.
*/
public function reassignStatus(int $ownerId, int $projectId, int $fromStatusId, int $toStatusId): void
{
$ids = $this->idsInColumn($ownerId, $projectId, $fromStatusId);
if ($ids === []) {
return;
}
$next = $this->nextPositionInColumn($ownerId, $projectId, $toStatusId);
$stmt = $this->pdo->prepare(
'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND owner_id = :owner'
);
foreach ($ids as $i => $id) {
$stmt->execute(['status' => $toStatusId, 'position' => $next + $i, 'id' => $id, 'owner' => $ownerId]);
}
}
private function insert(
int $ownerId,
?int $projectId,
+104 -2
View File
@@ -8,8 +8,9 @@ use PDO;
/**
* Data access for the `card_statuses` table. Statuses belong to a single
* project. There is no CRUD yet — projects are seeded with a fixed set on
* creation and that is the only way rows appear.
* project; every new project is seeded with a default set, and after that
* they're managed from the project's configuration view (create, reorder,
* delete).
*
* @phpstan-type CardStatusRow array{
* id: int, project_id: int, name: string, position: int,
@@ -51,6 +52,14 @@ final class CardStatusRepository
return $row === false ? null : $this->cast($row);
}
public function countForProject(int $projectId): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM card_statuses WHERE project_id = :project');
$stmt->execute(['project' => $projectId]);
return (int) $stmt->fetchColumn();
}
/** Insert the default status set for a freshly created project. */
public function seedDefaults(int $projectId): void
{
@@ -63,6 +72,99 @@ final class CardStatusRepository
}
}
/** Add a status at the end of the project's list. */
public function create(int $projectId, string $name): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO card_statuses (project_id, name, position) VALUES (:project, :name, :position)'
);
$stmt->execute(['project' => $projectId, 'name' => $name, 'position' => $this->nextPosition($projectId)]);
/** @var CardStatusRow $status */
$status = $this->find((int) $this->pdo->lastInsertId());
return $status;
}
public function delete(int $id): void
{
$this->pdo->prepare('DELETE FROM card_statuses WHERE id = :id')->execute(['id' => $id]);
}
/**
* Reorder a project's statuses to match $orderedIds (0..n-1).
*
* @param int[] $orderedIds
* @return CardStatusRow[]
*/
public function reorder(int $projectId, array $orderedIds): array
{
$stmt = $this->pdo->prepare(
'UPDATE card_statuses SET position = :position, updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND project_id = :project'
);
foreach (array_values($orderedIds) as $position => $id) {
$stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]);
}
return $this->allForProject($projectId);
}
/** Rewrite a project's status positions to a dense 0..n-1, current order preserved. */
public function repack(int $projectId): void
{
$stmt = $this->pdo->prepare('UPDATE card_statuses SET position = :position WHERE id = :id');
foreach (array_column($this->allForProject($projectId), 'id') as $position => $id) {
$stmt->execute(['position' => $position, 'id' => $id]);
}
}
/**
* Run $fn in a transaction on the connection shared with the other
* repositories (they're all built on the same PDO instance -- see
* bootstrap.php) -- for operations, like deleting a status, that need to
* write through more than one repository atomically.
*/
public function transaction(callable $fn): mixed
{
$this->pdo->beginTransaction();
try {
$result = $fn();
$this->pdo->commit();
return $result;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
private function nextPosition(int $projectId): int
{
$stmt = $this->pdo->prepare('SELECT COALESCE(MAX(position), -1) + 1 FROM card_statuses WHERE project_id = :project');
$stmt->execute(['project' => $projectId]);
return (int) $stmt->fetchColumn();
}
/**
* @return CardStatusRow|null
*/
private function find(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM card_statuses WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $this->cast($row);
}
private function nowExpr(): string
{
return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')";
}
/**
* @param array<string, mixed> $row
* @return CardStatusRow
+4 -1
View File
@@ -73,7 +73,7 @@ $authController = new AuthController($users, $session, $verifier, $config->allow
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
$projectController = new ProjectController($projects, $cardStatuses);
$cardController = new CardController($projects, $cards, $cardStatuses);
$cardStatusController = new CardStatusController($projects, $cardStatuses);
$cardStatusController = new CardStatusController($projects, $cardStatuses, $cards);
$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session);
$authMiddleware = new AuthMiddleware($jwt, $users);
@@ -120,6 +120,9 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']);
$projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']);
$projects->post('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'store']);
$projects->put('/{projectId:[0-9]+}/statuses/order', [$cardStatusController, 'reorder']);
$projects->delete('/{projectId:[0-9]+}/statuses/{statusId:[0-9]+}', [$cardStatusController, 'destroy']);
$projects->get('/{projectId:[0-9]+}/cards', [$cardController, 'index']);
$projects->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']);