Rename lists -> projects and items -> cards throughout

Project scope shifts from a todo list to a project-management app. This is a
straight terminology rename across code, comments, migrations, tests, and
docs — no behaviour change.

- DB: table todo_lists -> projects, todo_items -> cards, column
  todo_items.list_id -> cards.project_id, indexes renamed. Migrations 003/004
  rewritten in place (destructive; recreate the volume with `down -v`).
- API: /api/lists -> /api/projects, nested /items -> /cards, reorder body
  item_ids -> card_ids, JSON keys list/lists/item/items -> project/projects/
  card/cards, item_count -> card_count, list_id -> project_id, and the
  matching error messages.
- PHP: TodoList/TodoItem Repository + Controller -> Project/Card; shared SQL
  aliases l/i -> p/c.
- Frontend: stores lists.ts/items.ts -> projects.ts/cards.ts (useProjectsStore
  / useCardsStore, MAX_PROJECTS), ListView -> ProjectView, TodoItemRow ->
  CardRow, route /lists/:id -> /projects/:id (name "project"), types TodoList/
  TodoItem -> Project/Card, and all UI copy. CSS .lists*/.list-head* ->
  .projects*/.project-head*, .item* -> .card-row* (kept the generic .card
  panel class), .items -> .cards.
- Product name in the header, PWA manifest, index.html title and package
  descriptions -> "Project Manager" / "Projects".

Backend suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 11:28:59 +01:00
co-authored by Claude Sonnet 5
parent be592f38fc
commit c66e5ceb9b
30 changed files with 1068 additions and 1065 deletions
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exception\ApiException;
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;
public function __construct(private readonly ProjectRepository $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);
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'],
];
}
}