docs: v1 parity gaps implementation plan
This commit is contained in:
246
docs/superpowers/plans/2026-05-14-v1-parity-gaps.md
Normal file
246
docs/superpowers/plans/2026-05-14-v1-parity-gaps.md
Normal file
@@ -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<AppState>, AuthUser(uid): AuthUser) -> Result<Json<UserResponse>, 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<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Query(q): Query<PaginationQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_thoughts_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(tag_name): Path<String>,
|
||||||
|
Query(q): Query<PaginationQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<AppState>) -> Json<serde_json::Value> {
|
||||||
|
// 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<UserResponse>` — 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
|
||||||
Reference in New Issue
Block a user