From e408a531361e72ae03457903c39aa2f403d39de2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 11:19:29 +0200 Subject: [PATCH] docs: v1 parity gaps implementation plan --- .../plans/2026-05-14-v1-parity-gaps.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-v1-parity-gaps.md diff --git a/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md b/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md new file mode 100644 index 0000000..c4a750e --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md @@ -0,0 +1,246 @@ +# v1 Parity Gaps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close four endpoints present in v1 but missing from v2: `GET /users/me`, `GET /users/{username}/thoughts`, `GET /tags/{name}`, and `GET /health`. + +**Architecture:** All data layer work is already done — repositories, use cases, and response types exist. This plan is purely presentation layer additions: new handler functions in existing files, new routes registered in `routes.rs`. No domain or application changes needed. + +**Tech Stack:** axum 0.8, existing AppState ports + +--- + +## File Map + +``` +Modify: crates/presentation/src/handlers/users.rs ← add get_me handler +Modify: crates/presentation/src/handlers/feed.rs ← add user_thoughts + tag_thoughts handlers +Modify: crates/presentation/src/routes.rs ← register 4 new routes +Create: crates/presentation/src/handlers/health.rs ← health check handler +Modify: crates/presentation/src/handlers/mod.rs ← pub mod health +``` + +--- + +### Task 1: GET /users/me, GET /users/{username}/thoughts, GET /tags/{name} + +**Files:** +- Modify: `crates/presentation/src/handlers/users.rs` +- Modify: `crates/presentation/src/handlers/feed.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Add `get_me` handler** to `crates/presentation/src/handlers/users.rs` — append after `patch_profile`: + +```rust +pub async fn get_me(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { + let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; + Ok(Json(to_user_response(&user))) +} +``` + +- [ ] **Add `user_thoughts_handler` and `tag_thoughts_handler`** to `crates/presentation/src/handlers/feed.rs` — append after `get_followers_handler`: + +```rust +pub async fn user_thoughts_handler( + State(s): State, + Path(username): Path, + Query(q): Query, +) -> Result, ApiError> { + use application::use_cases::feed::get_user_feed; + let user = get_user_by_username(&*s.users, &username).await?; + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_user_feed(&*s.thoughts, &user.id, page).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(|e| serde_json::json!({ + "id": e.thought.id.as_uuid(), + "content": e.thought.content.as_str(), + "visibility": e.thought.visibility.as_str(), + "like_count": e.like_count, + "boost_count": e.boost_count, + "reply_count": e.reply_count, + "created_at": e.thought.created_at, + "updated_at": e.thought.updated_at, + })).collect::>() + }))) +} + +pub async fn tag_thoughts_handler( + State(s): State, + Path(tag_name): Path, + Query(q): Query, +) -> Result, ApiError> { + use application::use_cases::feed::get_by_tag; + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_by_tag(&*s.tags, &tag_name, page).await?; + Ok(Json(serde_json::json!({ + "tag": tag_name, + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(|t| serde_json::json!({ + "id": t.id.as_uuid(), + "content": t.content.as_str(), + "visibility": t.visibility.as_str(), + "created_at": t.created_at, + })).collect::>() + }))) +} +``` + +Note: `get_user_by_username`, `PageParams`, `PaginationQuery` are already imported in `feed.rs`. Only `get_user_feed` and `get_by_tag` need adding to the `use application::use_cases::feed::` import line at the top. Check the existing import and extend it. + +- [ ] **Register the three new routes** in `crates/presentation/src/routes.rs` — add to `api_routes`: + +```rust + // GET /users/me must be registered before /users/{username} to take precedence + .route("/users/me", get(users::get_me).patch(users::patch_profile)) + .route("/users/{username}/thoughts", get(feed::user_thoughts_handler)) + .route("/tags/{name}", get(feed::tag_thoughts_handler)) +``` + +**Important:** The existing routes have `/users/me` only for PATCH. Replace that line: + +Find: +```rust + .route("/users/me", patch(users::patch_profile)) +``` + +Replace with: +```rust + .route("/users/me", get(users::get_me).patch(users::patch_profile)) +``` + +And add `/users/{username}/thoughts` and `/tags/{name}` anywhere in `api_routes`. + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. + +- [ ] **Smoke test:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ +BASE_URL=http://localhost:3000 cargo run -p presentation & +sleep 2 + +TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \ + -H 'content-type: application/json' \ + -d '{"username":"parity","email":"parity@test.com","password":"pw"}' | jq -r .token) + +# GET /users/me +curl -s http://localhost:3000/users/me -H "Authorization: Bearer $TOKEN" | jq .username + +# POST a thought then fetch by tag (needs tag to exist) +curl -s -X POST http://localhost:3000/thoughts \ + -H 'content-type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"content":"hello world"}' > /dev/null + +# GET /users/{username}/thoughts +curl -s "http://localhost:3000/users/parity/thoughts" | jq '.total' + +# GET /tags/{name} (tag may be empty if no tagged thoughts) +curl -s "http://localhost:3000/tags/welcome" | jq '.tag' + +kill %1 +``` + +Expected: `username` = `"parity"`, `total` = 1, `tag` = `"welcome"`. + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/handlers/users.rs \ + crates/presentation/src/handlers/feed.rs \ + crates/presentation/src/routes.rs +git commit -m "feat(presentation): GET /users/me, GET /users/{username}/thoughts, GET /tags/{name}" +``` + +--- + +### Task 2: GET /health + +**Files:** +- Create: `crates/presentation/src/handlers/health.rs` +- Modify: `crates/presentation/src/handlers/mod.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Create `crates/presentation/src/handlers/health.rs`:** + +```rust +use axum::{extract::State, Json}; +use crate::state::AppState; + +pub async fn health_handler(State(s): State) -> Json { + // Cheap liveness check: verify DB connectivity + let db_ok = s.users.list_with_stats().await.is_ok(); + Json(serde_json::json!({ + "status": if db_ok { "ok" } else { "degraded" }, + "db": if db_ok { "connected" } else { "error" }, + })) +} +``` + +- [ ] **Add `pub mod health;`** to `crates/presentation/src/handlers/mod.rs`. + +- [ ] **Register the route** in `crates/presentation/src/routes.rs` — add to `api_routes`: + +```rust + .route("/health", get(health::health_handler)) +``` + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. + +- [ ] **Run full test suite:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 +``` + +Expected: all tests pass. + +- [ ] **Smoke test:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \ +BASE_URL=http://localhost:3000 cargo run -p presentation & +sleep 2 +curl -s http://localhost:3000/health | jq . +kill %1 +``` + +Expected: `{"status":"ok","db":"connected"}`. + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/handlers/health.rs \ + crates/presentation/src/handlers/mod.rs \ + crates/presentation/src/routes.rs +git commit -m "feat(presentation): GET /health endpoint" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `GET /users/me` — returns authenticated user's profile (Task 1) +- ✅ `GET /users/{username}/thoughts` — paginated thought list for any user (Task 1) +- ✅ `GET /tags/{name}` — paginated thoughts by tag name (Task 1) +- ✅ `GET /health` — DB connectivity check returning JSON status (Task 2) + +**Placeholder scan:** None. + +**Type consistency:** +- `get_me` returns `Json` — same type as `get_user`, consistent +- `user_thoughts_handler` calls `get_user_feed(&*s.thoughts, ...)` — matches use case signature in `feed.rs` +- `tag_thoughts_handler` calls `get_by_tag(&*s.tags, ...)` — matches use case signature +- `health_handler` calls `s.users.list_with_stats()` — exists on `UserRepository` port + +**Notes:** +- `/users/me` with GET + PATCH on the same route object — axum handles this with `.get(...).patch(...)` +- Static `/users/me` takes precedence over `/users/{username}` in axum route matching, so no conflict even though both patterns exist +- `list_with_stats()` does a DB query; acceptable for a health check — returns quickly and confirms DB connectivity +- `/tags/{name}` matches `{name}` not `{tagName}` — consistent with Rust naming convention