Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -89,6 +90,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/*`.
Expand Down
3 changes: 3 additions & 0 deletions backend/include/taskboard/task_store.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions backend/src/api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions backend/src/task_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/api_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"}});
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/task_store_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
13 changes: 13 additions & 0 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -116,6 +127,8 @@
}
});

clearDoneButton.addEventListener("click", clearDone);

checkHealth();
refresh();
setInterval(checkHealth, 15000);
Expand Down
5 changes: 4 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ <h2>In progress</h2>
<ul class="tasks" id="col-in_progress"></ul>
</div>
<div class="column" data-status="done">
<h2>Done</h2>
<div class="column-head">
<h2>Done</h2>
<button id="clear-done" class="btn-clear" type="button" title="Delete all done tasks">Clear done</button>
</div>
<ul class="tasks" id="col-done"></ul>
</div>
</section>
Expand Down
4 changes: 4 additions & 0 deletions frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading