De-duplicate controller boilerplate: owned-project lookup, reorder validation

requireOwnedProjectId() was copy-pasted identically in CardController
and CardStatusController; ProjectController's own
requireOwnedProject() was the same lookup, just returning the full
row instead of the id. New abstract ProjectScopedController (extends
Controller) holds one copy of both, and all three controllers now
extend it instead of Controller directly, forwarding their
ProjectRepository to its constructor.

Separately, CardController::reorder() (card_ids) and
CardStatusController::reorder() (status_ids) each had the same inline
'must be an array of ids, no duplicates' check. Both now call a new
Validator::intIdArray(), which does the same shape check once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:45:17 +01:00
co-authored by Claude Sonnet 5
parent accc6a273c
commit a85bb182c7
5 changed files with 95 additions and 68 deletions
+32
View File
@@ -90,6 +90,38 @@ final class Validator
return $value;
}
/**
* A required array of unique integer ids -- an ordered list of card or
* status ids to reorder, say. Shape only; whether the ids actually exist
* or are owned by the caller is the caller's job.
*
* @return int[]
*/
public function intIdArray(string $field): array
{
if (!$this->has($field)) {
$this->errors[$field][] = ucfirst($field) . ' is required.';
return [];
}
$value = $this->data[$field];
if (!is_array($value) || array_filter($value, static fn ($id): bool => !is_int($id)) !== []) {
$this->errors[$field][] = ucfirst($field) . ' must be an array of IDs.';
return [];
}
/** @var int[] $value */
if (count($value) !== count(array_unique($value))) {
$this->errors[$field][] = ucfirst($field) . ' must not contain duplicates.';
return [];
}
return $value;
}
public function add(string $field, string $message): void
{
$this->errors[$field][] = $message;