Files
thoughts/docs/superpowers/plans/2026-05-14-v1-parity-gaps.md

8.9 KiB

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:

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:
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:
        // 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:

        .route("/users/me",                    patch(users::patch_profile))

Replace with:

        .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:

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:
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:

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:

        .route("/health", get(health::health_handler))
  • Run: cargo check -p presentation — Expected: no errors.

  • Run full test suite:

DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3

Expected: all tests pass.

  • Smoke test:
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:
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