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>
127 lines
4.2 KiB
PHP
127 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Exception\ApiException;
|
|
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;
|
|
|
|
/**
|
|
* CRUD for the authenticated user's projects. A project is only ever visible to
|
|
* its owner; anything else responds 404.
|
|
*/
|
|
final class ProjectController extends ProjectScopedController
|
|
{
|
|
private const TITLE_MAX = 255;
|
|
private const DESCRIPTION_MAX = 2000;
|
|
private const MAX_PROJECTS_PER_OWNER = 100;
|
|
|
|
public function __construct(
|
|
ProjectRepository $projects,
|
|
private readonly CardStatusRepository $statuses,
|
|
) {
|
|
parent::__construct($projects);
|
|
}
|
|
|
|
/**
|
|
* GET /api/projects
|
|
*/
|
|
public function index(Request $request, Response $response): Response
|
|
{
|
|
$projects = $this->projects->allForOwner($this->user($request)['id']);
|
|
|
|
return $this->json($response, ['projects' => array_map($this->present(...), $projects)]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/projects
|
|
*/
|
|
public function store(Request $request, Response $response): Response
|
|
{
|
|
$ownerId = $this->user($request)['id'];
|
|
|
|
$validator = new Validator($this->body($request));
|
|
$title = $validator->requiredString('title', self::TITLE_MAX);
|
|
$description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? '';
|
|
$validator->assert();
|
|
|
|
if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) {
|
|
throw new ApiException(
|
|
sprintf('You have reached the maximum of %d projects.', self::MAX_PROJECTS_PER_OWNER),
|
|
409,
|
|
);
|
|
}
|
|
|
|
$project = $this->projects->create($ownerId, $title, $description);
|
|
$this->statuses->seedDefaults($project['id']);
|
|
|
|
return $this->json($response, ['project' => $this->present($project)], 201);
|
|
}
|
|
|
|
/**
|
|
* GET /api/projects/{projectId}
|
|
*/
|
|
public function show(Request $request, Response $response, array $args): Response
|
|
{
|
|
return $this->json($response, ['project' => $this->present($this->requireOwnedProject($request, $args))]);
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/projects/{projectId}
|
|
*/
|
|
public function update(Request $request, Response $response, array $args): Response
|
|
{
|
|
$project = $this->requireOwnedProject($request, $args);
|
|
|
|
$validator = new Validator($this->body($request));
|
|
$fields = [];
|
|
if ($validator->has('title')) {
|
|
$fields['title'] = $validator->requiredString('title', self::TITLE_MAX);
|
|
}
|
|
if ($validator->has('description')) {
|
|
$fields['description'] = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? '';
|
|
}
|
|
if ($fields === [] && !$validator->failed()) {
|
|
$validator->add('title', 'Provide at least one of: title, description.');
|
|
}
|
|
$validator->assert();
|
|
|
|
$updated = $this->projects->update($project['id'], $project['owner_id'], $fields);
|
|
|
|
return $this->json($response, ['project' => $this->present($updated)]);
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/projects/{projectId}
|
|
*/
|
|
public function destroy(Request $request, Response $response, array $args): Response
|
|
{
|
|
$this->projects->delete($this->requireOwnedProject($request, $args)['id']);
|
|
|
|
return $response->withStatus(204);
|
|
}
|
|
|
|
/**
|
|
* @param array{id: int, owner_id: int, title: string, description: string, card_count: int, completed_count: int, created_at: string, updated_at: string} $project
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function present(array $project): array
|
|
{
|
|
return [
|
|
'id' => $project['id'],
|
|
'title' => $project['title'],
|
|
'description' => $project['description'],
|
|
'owner_id' => $project['owner_id'],
|
|
'card_count' => $project['card_count'],
|
|
'completed_count' => $project['completed_count'],
|
|
'created_at' => $project['created_at'],
|
|
'updated_at' => $project['updated_at'],
|
|
];
|
|
}
|
|
}
|