From e2f0b506bb9371c2f951a5fb379ff50974b51a40 Mon Sep 17 00:00:00 2001 From: Merve Doker Date: Thu, 17 Sep 2026 14:06:51 +0300 Subject: [PATCH 1/2] feat(api): bulk delete tasks by status and 'Clear done' button - TaskStore::remove_by_status + unit test - DELETE /api/tasks?status= returns {removed:n}; 400 on missing/unknown status - frontend: 'Clear done' button in Done column, disabled when empty - README: document new endpoint Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + backend/include/taskboard/task_store.hpp | 3 +++ backend/src/api.cpp | 14 ++++++++++++++ backend/src/task_store.cpp | 14 ++++++++++++++ backend/tests/api_test.cpp | 20 ++++++++++++++++++++ backend/tests/task_store_test.cpp | 12 ++++++++++++ frontend/app.js | 13 +++++++++++++ frontend/index.html | 5 ++++- frontend/styles.css | 4 ++++ 9 files changed, 85 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0434444..6a22b67 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ optional for local development. | GET | `/api/tasks/{id}` | Fetch one task | | PUT | `/api/tasks/{id}` | Partial update of title/description/status | | DELETE | `/api/tasks/{id}` | Remove task (204) | +| DELETE | `/api/tasks?status=done` | Bulk remove by status, returns `{removed}` | Statuses: `todo`, `in_progress`, `done`. Errors return `{"error": "..."}` with 400 or 404. diff --git a/backend/include/taskboard/task_store.hpp b/backend/include/taskboard/task_store.hpp index 225e970..687d6cf 100644 --- a/backend/include/taskboard/task_store.hpp +++ b/backend/include/taskboard/task_store.hpp @@ -37,6 +37,9 @@ class TaskStore { bool remove(std::uint64_t id); + /// Removes every task with the given status. Returns how many were deleted. + std::size_t remove_by_status(Status status); + Stats stats() const; std::size_t size() const; void clear(); diff --git a/backend/src/api.cpp b/backend/src/api.cpp index 3b9e2f5..5b6b6a9 100644 --- a/backend/src/api.cpp +++ b/backend/src/api.cpp @@ -93,6 +93,20 @@ void register_routes(httplib::Server& server, TaskStore& store) { } }); + // Bulk delete: DELETE /api/tasks?status=done + server.Delete("/api/tasks", [&store](const httplib::Request& req, httplib::Response& res) { + if (!req.has_param("status")) { + send_error(res, 400, "status query parameter is required"); + return; + } + const auto status = status_from_string(req.get_param_value("status")); + if (!status) { + send_error(res, 400, "unknown status filter"); + return; + } + send_json(res, 200, json{{"removed", store.remove_by_status(*status)}}); + }); + server.Get(R"(/api/tasks/(\d+))", [&store](const httplib::Request& req, httplib::Response& res) { const auto id = parse_id(req.matches[1]); if (!id) { diff --git a/backend/src/task_store.cpp b/backend/src/task_store.cpp index 1081db9..9c5fe1c 100644 --- a/backend/src/task_store.cpp +++ b/backend/src/task_store.cpp @@ -68,6 +68,20 @@ bool TaskStore::remove(std::uint64_t id) { return tasks_.erase(id) > 0; } +std::size_t TaskStore::remove_by_status(Status status) { + std::lock_guard lock(mutex_); + std::size_t removed = 0; + for (auto it = tasks_.begin(); it != tasks_.end();) { + if (it->second.status == status) { + it = tasks_.erase(it); + ++removed; + } else { + ++it; + } + } + return removed; +} + Stats TaskStore::stats() const { std::lock_guard lock(mutex_); Stats s; diff --git a/backend/tests/api_test.cpp b/backend/tests/api_test.cpp index 7476d23..ae1f6a6 100644 --- a/backend/tests/api_test.cpp +++ b/backend/tests/api_test.cpp @@ -140,6 +140,26 @@ TEST_F(ApiTest, DeleteRemovesTask) { EXPECT_EQ(again->status, 404); } +TEST_F(ApiTest, BulkDeleteByStatus) { + post_task({{"title", "a"}, {"status", "done"}}); + post_task({{"title", "b"}}); + post_task({{"title", "c"}, {"status", "done"}}); + + auto res = client_->Delete("/api/tasks?status=done"); + ASSERT_TRUE(res); + EXPECT_EQ(res->status, 200); + EXPECT_EQ(json::parse(res->body)["removed"], 2); + EXPECT_EQ(store_.size(), 1u); + + auto missing_param = client_->Delete("/api/tasks"); + ASSERT_TRUE(missing_param); + EXPECT_EQ(missing_param->status, 400); + + auto bad_status = client_->Delete("/api/tasks?status=nope"); + ASSERT_TRUE(bad_status); + EXPECT_EQ(bad_status->status, 400); +} + TEST_F(ApiTest, ListFilterAndStats) { post_task({{"title", "a"}}); post_task({{"title", "b"}, {"status", "done"}}); diff --git a/backend/tests/task_store_test.cpp b/backend/tests/task_store_test.cpp index fc22343..937b058 100644 --- a/backend/tests/task_store_test.cpp +++ b/backend/tests/task_store_test.cpp @@ -105,6 +105,18 @@ TEST(TaskStore, RemoveReportsWhetherSomethingWasDeleted) { EXPECT_EQ(store.size(), 0u); } +TEST(TaskStore, RemoveByStatusDeletesOnlyMatching) { + TaskStore store; + store.create(input("a", "", Status::Done)); + store.create(input("b", "", Status::Todo)); + store.create(input("c", "", Status::Done)); + + EXPECT_EQ(store.remove_by_status(Status::Done), 2u); + EXPECT_EQ(store.size(), 1u); + EXPECT_EQ(store.list().front().title, "b"); + EXPECT_EQ(store.remove_by_status(Status::Done), 0u); +} + TEST(TaskStore, StatsCountPerStatus) { TaskStore store; store.create(input("a", "", Status::Todo)); diff --git a/frontend/app.js b/frontend/app.js index 04e468c..9ed9492 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -12,6 +12,7 @@ const errorBox = document.getElementById("error"); const healthDot = document.getElementById("health"); const template = document.getElementById("task-template"); + const clearDoneButton = document.getElementById("clear-done"); async function api(path, options = {}) { const res = await fetch(API_BASE + path, { @@ -57,6 +58,16 @@ for (const key of ["total", ...STATUSES]) { document.getElementById(`stat-${key}`).textContent = stats[key] ?? 0; } + clearDoneButton.disabled = (stats.done ?? 0) === 0; + } + + async function clearDone() { + try { + await api("/tasks?status=done", { method: "DELETE" }); + await refresh(); + } catch (err) { + showError(err.message); + } } async function refresh() { @@ -116,6 +127,8 @@ } }); + clearDoneButton.addEventListener("click", clearDone); + checkHealth(); refresh(); setInterval(checkHealth, 15000); diff --git a/frontend/index.html b/frontend/index.html index abc55cf..3899af9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,7 +36,10 @@

In progress

    -

    Done

    +
    +

    Done

    + +
      diff --git a/frontend/styles.css b/frontend/styles.css index fb7243e..8f6bfa3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -103,6 +103,10 @@ button:disabled { opacity: 0.5; cursor: default; } color: var(--muted); } +.column-head { display: flex; align-items: center; justify-content: space-between; } +.column-head h2 { margin-bottom: 8px; } +.btn-clear { padding: 2px 8px; font-size: 0.75rem; margin-bottom: 8px; color: var(--danger); } + .tasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .task { From 3ce9cb86734c50c654aa70798afadbfe102635bb Mon Sep 17 00:00:00 2001 From: Merve Doker Date: Thu, 17 Sep 2026 14:07:46 +0300 Subject: [PATCH 2/2] docs(readme): running notes, handled edge cases and production checklist Co-Authored-By: Claude Fable 5.1 --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/README.md b/README.md index 0434444..82606a1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,92 @@ docker-compose.yml * **CD** runs on pushes to `main` and `v*` tags and publishes `ghcr.io/dkrmerve/cpp-taskboard-backend` and `ghcr.io/dkrmerve/cpp-taskboard-frontend`. +## Running notes + +### Ports and environment + +| Variable | Default | Used by | Meaning | +|--------------|-----------|----------|------------------------------------------------------| +| `PORT` | `8080` | backend | HTTP listen port | +| `HOST` | `0.0.0.0` | backend | Bind address (`127.0.0.1` to keep it local-only) | +| `STATIC_DIR` | *(unset)* | backend | If set, serves that folder at `/` (frontend without nginx) | + +| Host port | Service | Notes | +|-----------|----------|-----------------------------------------| +| `3000` | frontend | nginx; `/api/*` is proxied to backend | +| `8080` | backend | Direct API access, handy for curl/Postman | + +### Useful commands + +```bash +docker compose up --build -d # start in background +docker compose logs -f backend # request log: "POST /api/tasks -> 201" +docker compose ps # both services should say (healthy) +docker compose down # stop; add -v to drop volumes (none today) +``` + +### Troubleshooting + +* **Frontend loads but shows a red dot** – backend is down or not yet healthy. + `docker compose logs backend` and `curl localhost:8080/api/health`. +* **Port already in use** – change the host side of the mapping in + `docker-compose.yml` (`"3001:80"`), the container side stays as is. +* **Container reports unhealthy on Alpine/nginx** – healthchecks must use + `127.0.0.1`, not `localhost` (resolves to IPv6 first, nginx listens on IPv4). +* **`docker build` slow the first time** – FetchContent downloads cpp-httplib, + nlohmann/json and GoogleTest; later builds hit the layer cache. +* **Data disappears on restart** – expected, the store is in-memory (see below). + +## Edge cases handled + +| Case | Behaviour | Covered by test | +|---------------------------------------------|---------------------------------------------|-----------------| +| Empty / whitespace-only title | 400 `title is required`, nothing stored | `CreateTrimsTitleAndRejectsEmpty`, `CreateValidatesInput` | +| Title padded with spaces | Trimmed before storing | `CreateTrimsTitleAndRejectsEmpty` | +| Update that would blank the title | 400, original task untouched | `UpdateRejectsEmptyTitleAndKeepsOriginal` | +| Empty body / malformed JSON / wrong field type | 400 with a specific error message | `CreateValidatesInput`, `ParsePatch.RejectsWrongTypes` | +| Unknown status value (`"DONE"`, `"in-progress"`) | 400, only `todo`, `in_progress`, `done` accepted | `Status.RejectsUnknownStrings` | +| Unknown `?status=` filter | 400 instead of silently returning nothing | `ListFilterAndStats` | +| Non-existent id on GET/PUT/DELETE | 404 | `GetByIdAnd404`, `UpdateChangesStatus`, `DeleteRemovesTask` | +| Non-numeric id (`/api/tasks/abc`) | Route does not match → 404 | `GetByIdAnd404` | +| Deleting the same task twice | Second call returns 404 | `DeleteRemovesTask` | +| Concurrent creates from many threads | Mutex-guarded store, ids stay unique and ordered | `ConcurrentCreatesProduceUniqueIds` | +| Partial update (`{"status": "done"}` only) | Other fields keep their values | `UpdateAppliesPartialPatch` | +| Browser on a different origin | CORS headers + `OPTIONS` preflight → 204 | `CorsHeadersArePresent` | + +Known limits, by design for this version: ids are 64-bit and never reused; +there is no maximum title length on the API side (the UI caps at 120/500 chars); +`created_at` is second-precision UTC. + +## Before going to production + +The stack is a complete, tested demo. Before exposing it to real users: + +- [ ] **Persistence** – the store is in-memory, so a restart wipes every task. Swap + `TaskStore` for SQLite/PostgreSQL behind the same interface (the API and + tests are already decoupled from the storage). +- [ ] **Authentication / authorisation** – every endpoint is public. Put the API + behind a reverse proxy with auth, or add token checks in `register_routes`. +- [ ] **Lock down CORS** – `Access-Control-Allow-Origin: *` is for development. + Set it to the real frontend origin. +- [ ] **TLS** – terminate HTTPS at nginx or a load balancer; the backend speaks + plain HTTP. +- [ ] **Request limits** – set `server.set_payload_max_length(...)`, read/write + timeouts and a title length cap to avoid abuse. +- [ ] **Graceful shutdown** – handle `SIGTERM` and call `server.stop()` so + rolling deploys do not cut requests mid-flight. +- [ ] **Observability** – switch the stdout logger to structured JSON logs, add a + `/metrics` endpoint (Prometheus) and a request-id header. +- [ ] **Resource limits** – add `deploy.resources.limits` (or Kubernetes + requests/limits) and a non-`unless-stopped` restart policy suited to the + orchestrator. +- [ ] **Image hygiene** – pin base images by digest, run Trivy/Grype in CI, and + tag releases (`v1.0.0`) so CD publishes immutable versions. +- [ ] **Secrets / config** – keep environment in a secrets manager, never in the + compose file committed to git. +- [ ] **Backups & migrations** – once persistence exists, script schema + migrations and automated backups before the first real deploy. + ## Branching `main` (production) ← `develop` (integration) ← `feature/*`, `fix/*`.