Drop the unused project description field entirely

It never got a UI home on the frontend -- removed from ProjectView a
few sessions back and never restored anywhere -- so it was a fully
live field (validated, stored, returned by the API, covered by tests)
with no consumer.

- Migration 011: ALTER TABLE projects DROP COLUMN description.
- ProjectRepository: description dropped from ProjectRow, SELECT,
  create() and update() -- update() is now just a rename (string, not
  a $fields array; there was never more than one editable field once
  this left).
- ProjectController: store()/update() no longer accept or validate
  it; update() drops the "provide at least one of title, description"
  branch, since title is unconditionally the only field now.
- Frontend Project type, root README's API docs and curl example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 01:25:53 +01:00
co-authored by Claude Sonnet 5
parent 367a98308a
commit fb59a54938
7 changed files with 22 additions and 50 deletions
+3 -5
View File
@@ -284,15 +284,14 @@ owner (the creator); another user's project — or a missing one — always resp
| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title | | `GET` | `/api/projects` | the caller's projects, sorted A→Z by title |
| `POST` | `/api/projects` | create a project | | `POST` | `/api/projects` | create a project |
| `GET` | `/api/projects/{id}` | one project | | `GET` | `/api/projects/{id}` | one project |
| `PATCH` | `/api/projects/{id}` | update `title` and/or `description` | | `PATCH` | `/api/projects/{id}` | rename the project (`title`) |
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | | `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
`GET /api/projects` is always ordered alphabetically (case-insensitive) by `GET /api/projects` is always ordered alphabetically (case-insensitive) by
title; there is no other sort option. A user may own at most **100 projects** title; there is no other sort option. A user may own at most **100 projects**
creating one beyond that responds `409`. creating one beyond that responds `409`.
Create/update body: `title` (required on create, 1255 chars), `description` Create/update body: `title` (required, 1255 chars).
(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field.
Project representation: Project representation:
@@ -301,7 +300,6 @@ Project representation:
"project": { "project": {
"id": 1, "id": 1,
"title": "Website relaunch", "title": "Website relaunch",
"description": "Q3",
"owner_id": 1, "owner_id": 1,
"card_count": 3, "card_count": 3,
"completed_count": 1, "completed_count": 1,
@@ -452,7 +450,7 @@ curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
PROJECT=$(curl -s -X POST $BASE/api/projects \ PROJECT=$(curl -s -X POST $BASE/api/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Website relaunch","description":"Q3"}' \ -d '{"title":"Website relaunch"}' \
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) | tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN" curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
@@ -0,0 +1,3 @@
-- The description field never got a UI home on the frontend and isn't used
-- anywhere; drop it rather than carry an unused column.
ALTER TABLE projects DROP COLUMN description;
+4 -16
View File
@@ -18,7 +18,6 @@ use Psr\Http\Message\ServerRequestInterface as Request;
final class ProjectController extends ProjectScopedController final class ProjectController extends ProjectScopedController
{ {
private const TITLE_MAX = 255; private const TITLE_MAX = 255;
private const DESCRIPTION_MAX = 2000;
private const MAX_PROJECTS_PER_OWNER = 100; private const MAX_PROJECTS_PER_OWNER = 100;
public function __construct( public function __construct(
@@ -47,7 +46,6 @@ final class ProjectController extends ProjectScopedController
$validator = new Validator($this->body($request)); $validator = new Validator($this->body($request));
$title = $validator->requiredString('title', self::TITLE_MAX); $title = $validator->requiredString('title', self::TITLE_MAX);
$description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? '';
$validator->assert(); $validator->assert();
if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) { if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) {
@@ -57,7 +55,7 @@ final class ProjectController extends ProjectScopedController
); );
} }
$project = $this->projects->create($ownerId, $title, $description); $project = $this->projects->create($ownerId, $title);
$this->statuses->seedDefaults($project['id']); $this->statuses->seedDefaults($project['id']);
return $this->json($response, ['project' => $this->present($project)], 201); return $this->json($response, ['project' => $this->present($project)], 201);
@@ -79,19 +77,10 @@ final class ProjectController extends ProjectScopedController
$project = $this->requireOwnedProject($request, $args); $project = $this->requireOwnedProject($request, $args);
$validator = new Validator($this->body($request)); $validator = new Validator($this->body($request));
$fields = []; $title = $validator->requiredString('title', self::TITLE_MAX);
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(); $validator->assert();
$updated = $this->projects->update($project['id'], $project['owner_id'], $fields); $updated = $this->projects->update($project['id'], $project['owner_id'], $title);
return $this->json($response, ['project' => $this->present($updated)]); return $this->json($response, ['project' => $this->present($updated)]);
} }
@@ -107,7 +96,7 @@ final class ProjectController extends ProjectScopedController
} }
/** /**
* @param array{id: int, owner_id: int, title: string, description: string, card_count: int, completed_count: int, created_at: string, updated_at: string} $project * @param array{id: int, owner_id: int, title: string, card_count: int, completed_count: int, created_at: string, updated_at: string} $project
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function present(array $project): array private function present(array $project): array
@@ -115,7 +104,6 @@ final class ProjectController extends ProjectScopedController
return [ return [
'id' => $project['id'], 'id' => $project['id'],
'title' => $project['title'], 'title' => $project['title'],
'description' => $project['description'],
'owner_id' => $project['owner_id'], 'owner_id' => $project['owner_id'],
'card_count' => $project['card_count'], 'card_count' => $project['card_count'],
'completed_count' => $project['completed_count'], 'completed_count' => $project['completed_count'],
@@ -24,7 +24,7 @@ abstract class ProjectScopedController extends Controller
* by the authenticated user. * by the authenticated user.
* *
* @param array<string, string> $args * @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} * @return array{id: int, owner_id: int, title: string, card_count: int, completed_count: int, created_at: string, updated_at: string}
*/ */
protected function requireOwnedProject(Request $request, array $args): array protected function requireOwnedProject(Request $request, array $args): array
{ {
+9 -21
View File
@@ -11,14 +11,14 @@ use PDO;
* Data access for the `projects` table. * Data access for the `projects` table.
* *
* @phpstan-type ProjectRow array{ * @phpstan-type ProjectRow array{
* id: int, owner_id: int, title: string, description: string, * id: int, owner_id: int, title: string,
* card_count: int, completed_count: int, created_at: string, updated_at: string * card_count: int, completed_count: int, created_at: string, updated_at: string
* } * }
*/ */
final class ProjectRepository final class ProjectRepository
{ {
private const SELECT = <<<'SQL' private const SELECT = <<<'SQL'
SELECT p.id, p.owner_id, p.title, p.description, p.created_at, p.updated_at, SELECT p.id, p.owner_id, p.title, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id) AS card_count, (SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id) AS card_count,
(SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id AND c.complete = 1) AS completed_count (SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id AND c.complete = 1) AS completed_count
FROM projects p FROM projects p
@@ -68,15 +68,12 @@ final class ProjectRepository
/** /**
* @return ProjectRow * @return ProjectRow
*/ */
public function create(int $ownerId, string $title, string $description): array public function create(int $ownerId, string $title): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare('INSERT INTO projects (owner_id, title) VALUES (:owner, :title)');
'INSERT INTO projects (owner_id, title, description) VALUES (:owner, :title, :description)'
);
$stmt->execute([ $stmt->execute([
'owner' => $ownerId, 'owner' => $ownerId,
'title' => $title, 'title' => $title,
'description' => $description,
]); ]);
/** @var ProjectRow $project */ /** @var ProjectRow $project */
@@ -86,23 +83,14 @@ final class ProjectRepository
} }
/** /**
* @param array{title?: string, description?: string} $fields
* @return ProjectRow * @return ProjectRow
*/ */
public function update(int $id, int $ownerId, array $fields): array public function update(int $id, int $ownerId, string $title): array
{ {
$sets = ['updated_at = ' . Database::nowExpr()]; $stmt = $this->pdo->prepare(
$params = ['id' => $id]; 'UPDATE projects SET title = :title, updated_at = ' . Database::nowExpr() . ' WHERE id = :id'
);
foreach (['title', 'description'] as $column) { $stmt->execute(['id' => $id, 'title' => $title]);
if (array_key_exists($column, $fields)) {
$sets[] = "{$column} = :{$column}";
$params[$column] = $fields[$column];
}
}
$stmt = $this->pdo->prepare('UPDATE projects SET ' . implode(', ', $sets) . ' WHERE id = :id');
$stmt->execute($params);
/** @var ProjectRow $project */ /** @var ProjectRow $project */
$project = $this->findOwnedBy($id, $ownerId); $project = $this->findOwnedBy($id, $ownerId);
+2 -6
View File
@@ -15,15 +15,11 @@ final class ProjectTest extends ApiTestCase
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$created = $this->request('POST', '/api/projects', [ $created = $this->request('POST', '/api/projects', ['title' => ' Groceries '], $auth);
'title' => ' Groceries ',
'description' => 'Weekly shop',
], $auth);
self::assertSame(201, $created->getStatusCode()); self::assertSame(201, $created->getStatusCode());
$project = $this->decode($created)['project']; $project = $this->decode($created)['project'];
self::assertSame('Groceries', $project['title']); self::assertSame('Groceries', $project['title']);
self::assertSame('Weekly shop', $project['description']);
self::assertSame(0, $project['card_count']); self::assertSame(0, $project['card_count']);
$index = $this->decode($this->request('GET', '/api/projects', null, $auth)); $index = $this->decode($this->request('GET', '/api/projects', null, $auth));
@@ -63,7 +59,7 @@ final class ProjectTest extends ApiTestCase
public function test_project_creation_validates_title(): void public function test_project_creation_validates_title(): void
{ {
$response = $this->request('POST', '/api/projects', ['description' => 'no title'], $this->authHeader()); $response = $this->request('POST', '/api/projects', [], $this->authHeader());
self::assertSame(422, $response->getStatusCode()); self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('title', $this->decode($response)['error']['details']); self::assertArrayHasKey('title', $this->decode($response)['error']['details']);
-1
View File
@@ -26,7 +26,6 @@ export interface Passkey {
export interface Project { export interface Project {
id: number id: number
title: string title: string
description: string
owner_id: number owner_id: number
card_count: number card_count: number
completed_count: number completed_count: number