2026-09-04 11:28:59 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
|
|
|
|
|
|
use App\Exception\ApiException;
|
2026-09-04 13:37:17 +01:00
|
|
|
use App\Repository\CardStatusRepository;
|
2026-09-04 11:28:59 +01:00
|
|
|
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 Controller
|
|
|
|
|
{
|
|
|
|
|
private const TITLE_MAX = 255;
|
|
|
|
|
private const DESCRIPTION_MAX = 2000;
|
|
|
|
|
private const MAX_PROJECTS_PER_OWNER = 100;
|
|
|
|
|
|
2026-09-04 13:37:17 +01:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly ProjectRepository $projects,
|
|
|
|
|
private readonly CardStatusRepository $statuses,
|
|
|
|
|
) {
|
2026-09-04 11:28:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-09-04 13:37:17 +01:00
|
|
|
$this->statuses->seedDefaults($project['id']);
|
2026-09-04 11:28:59 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load the project named in the route, or 404 if it is missing or not owned
|
|
|
|
|
* by the authenticated user.
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, string> $args
|
|
|
|
|
* @return array{id: int, owner_id: int, title: string, description: string, card_count: int, completed_count: int, created_at: string, updated_at: string}
|
|
|
|
|
*/
|
|
|
|
|
private function requireOwnedProject(Request $request, array $args): array
|
|
|
|
|
{
|
|
|
|
|
$project = $this->projects->findOwnedBy((int) $args['projectId'], $this->user($request)['id']);
|
|
|
|
|
|
|
|
|
|
if ($project === null) {
|
|
|
|
|
throw new ApiException('Project not found.', 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $project;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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'],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|