# REST API Cleanup 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:** Rename routes, unify local/remote follow, add content negotiation at `GET /users/{username}`, and switch notification state changes to PATCH — no new features, pure cleanup. **Architecture:** The domain `FederationActionPort` gains `actor_json` so the presentation layer can serve AP actor JSON without depending on `activitypub-base`. Content negotiation happens in a single handler that inspects the `Accept` header. The unified follow handler detects `@` in the path param to route local vs remote. All route string changes land in `routes.rs` and `main.rs`. **Tech Stack:** Rust (axum, domain ports), Next.js 15 (App Router), TypeScript, Zod. --- ## File Map | Action | Path | Change | |--------|------|--------| | Modify | `crates/domain/src/ports.rs` | Add `actor_json` to `FederationActionPort` | | Modify | `crates/domain/src/testing.rs` | Add `actor_json` to `TestStore` impl + test | | Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `actor_json`; fix handle format in `lookup_actor` | | Modify | `crates/api-types/src/requests.rs` | Add `NotificationUpdateRequest`; remove `FollowRemoteRequest` | | Modify | `crates/presentation/src/handlers/notifications.rs` | Replace POST handlers with PATCH | | Modify | `crates/presentation/src/handlers/users.rs` | Content negotiation in `get_user`; move `lookup_handler` from federation; rename `get_me_following_list` | | Modify | `crates/presentation/src/handlers/social.rs` | Unified `post_follow`; `delete_follow` rejects remote; fix OpenAPI `{id}`→`{username}` | | Delete | `crates/presentation/src/handlers/federation.rs` | Both handlers gone: `lookup_handler` → `users.rs`; `follow_remote_handler` → deleted | | Modify | `crates/presentation/src/handlers/mod.rs` | Remove `pub mod federation;` | | Modify | `crates/presentation/src/routes.rs` | All route string changes | | Modify | `crates/bootstrap/src/main.rs` | Remove `/users/{username}` from AP router | | Modify | `thoughts-frontend/lib/api.ts` | URL/method updates + new notification functions | | Modify | `thoughts-frontend/components/remote-user-card.tsx` | `followRemoteUser` → `followUser` | --- ## Task 1: Domain — add `actor_json` to `FederationActionPort` **Files:** - Modify: `crates/domain/src/ports.rs` - Modify: `crates/domain/src/testing.rs` - [ ] **Step 1: Add `actor_json` to the trait** Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait block, add the new method: ```rust #[async_trait] pub trait FederationActionPort: Send + Sync { async fn lookup_actor(&self, handle: &str) -> Result; async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; async fn actor_json(&self, user_id: &UserId) -> Result; } ``` - [ ] **Step 2: Write the failing test** At the bottom of the `federation_port_tests` module in `crates/domain/src/testing.rs`, add: ```rust #[tokio::test] async fn test_store_actor_json_returns_not_found() { let store = TestStore::default(); let err = store.actor_json(&UserId::new()).await.unwrap_err(); assert!(matches!(err, DomainError::NotFound)); } ``` - [ ] **Step 3: Run to see it fail** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 ``` Expected: compile error — `actor_json` not in `TestStore`'s `FederationActionPort` impl. - [ ] **Step 4: Implement `actor_json` on `TestStore`** In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add: ```rust async fn actor_json(&self, _user_id: &UserId) -> Result { Err(DomainError::NotFound) } ``` - [ ] **Step 5: Run tests to confirm pass** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10 ``` Expected: all 3 tests pass. - [ ] **Step 6: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 ``` - [ ] **Step 7: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/domain/src/ports.rs crates/domain/src/testing.rs git commit -m "feat(domain): add actor_json to FederationActionPort" ``` --- ## Task 2: activitypub-base — implement `actor_json` + fix handle format **Files:** - Modify: `crates/adapters/activitypub-base/src/service.rs` - [ ] **Step 1: Add compile-time assert** In `crates/adapters/activitypub-base/src/tests/service.rs`, the existing `_assert_impl_federation_action_port` function will now fail to compile because `actor_json` is missing. Run to confirm: ```bash cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10 ``` Expected: error about missing `actor_json` impl. - [ ] **Step 2: Implement `actor_json` in the `FederationActionPort` impl** Read `crates/adapters/activitypub-base/src/service.rs`. In the `impl domain::ports::FederationActionPort for ActivityPubService` block, add after `follow_remote`: ```rust async fn actor_json( &self, user_id: &domain::value_objects::UserId, ) -> Result { ActivityPubService::actor_json(self, &user_id.as_uuid().to_string()) .await .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) } ``` Note: `ActivityPubService::actor_json` is the existing inherent method at line ~210 that takes `&str`. Calling it as `ActivityPubService::actor_json(self, ...)` avoids ambiguity with the trait method. - [ ] **Step 3: Fix `lookup_actor` to return full `user@domain` handle** In the same file, find the `lookup_actor` impl. Currently it sets `handle: actor.username.clone()` (just the `preferred_username`). Replace the `Ok(...)` block with: ```rust let domain_str = actor.ap_id.host_str().unwrap_or(""); let full_handle = format!("{}@{}", actor.username, domain_str); Ok(domain::models::remote_actor::RemoteActor { url: actor.ap_id.to_string(), handle: full_handle, display_name: Some(actor.username.clone()), inbox_url: actor.inbox_url.to_string(), shared_inbox_url: None, public_key: actor.public_key_pem.clone(), avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()), last_fetched_at: actor.last_refreshed_at, }) ``` - [ ] **Step 4: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5 ``` Expected: no errors. - [ ] **Step 5: Full workspace check** ```bash cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5 ``` - [ ] **Step 6: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/adapters/activitypub-base/src/service.rs git commit -m "feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup" ``` --- ## Task 3: Notification handlers — PATCH **Files:** - Modify: `crates/api-types/src/requests.rs` - Modify: `crates/presentation/src/handlers/notifications.rs` - [ ] **Step 1: Add `NotificationUpdateRequest` and remove `FollowRemoteRequest`** Read `crates/api-types/src/requests.rs`. Remove the `FollowRemoteRequest` struct (it was only used by the federation handler being deleted). Add: ```rust #[derive(serde::Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NotificationUpdateRequest { pub read: bool, } ``` - [ ] **Step 2: Write failing tests** Add to `crates/presentation/src/handlers/notifications.rs` (inside a `#[cfg(test)] mod tests` block at the bottom, following the same pattern as `federation.rs` tests — use `TestStore` and `tower::ServiceExt::oneshot`): ```rust #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{Request, header}, routing::{get, patch}, Router, }; use domain::testing::TestStore; use std::sync::Arc; use tower::ServiceExt; // Re-use the same NoOpAuth/NoOpHasher stubs from federation.rs tests pattern: // Check crates/presentation/src/handlers/federation.rs for the exact stub code // and copy it here (NoOpAuth implementing AuthService, NoOpHasher implementing PasswordHasher). fn make_state() -> crate::state::AppState { let store = Arc::new(TestStore::default()); crate::state::AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: Arc::new(NoOpAuth), hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), } } fn app() -> Router { Router::new() .route("/notifications", patch(mark_all_read)) .route("/notifications/:id", patch(mark_notification_read)) .with_state(make_state()) } #[tokio::test] async fn patch_notification_without_auth_returns_401() { let resp = app() .oneshot( Request::builder() .method("PATCH") .uri("/notifications/00000000-0000-0000-0000-000000000001") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"read":true}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 401); } #[tokio::test] async fn patch_all_without_auth_returns_401() { let resp = app() .oneshot( Request::builder() .method("PATCH") .uri("/notifications") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"read":true}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 401); } } ``` Note: copy the `NoOpAuth` and `NoOpHasher` struct definitions from `crates/presentation/src/handlers/federation.rs` — they are defined inline in the test module there. - [ ] **Step 3: Run to see compile/test failure** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -20 ``` Expected: compile error — `mark_notification_read` and `mark_all_read` don't accept JSON body yet. - [ ] **Step 4: Replace the POST handlers with PATCH handlers** Replace the full content of `crates/presentation/src/handlers/notifications.rs` with: ```rust use api_types::requests::NotificationUpdateRequest; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; use application::use_cases::notifications::{ list_notifications as uc_list_notifications, mark_all_notifications_read, mark_notification_read as uc_mark_notification_read, }; use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use domain::{models::feed::PageParams, value_objects::NotificationId}; use uuid::Uuid; pub async fn list_notifications( State(s): State, AuthUser(uid): AuthUser, ) -> Result, ApiError> { let page = PageParams { page: 1, per_page: 20 }; let result = uc_list_notifications(&*s.notifications, &uid, page).await?; Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }))) } pub async fn mark_notification_read( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, Json(body): Json, ) -> Result { if body.read { uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?; } Ok(StatusCode::NO_CONTENT) } pub async fn mark_all_read( State(s): State, AuthUser(uid): AuthUser, Json(body): Json, ) -> Result { if body.read { mark_all_notifications_read(&*s.notifications, &uid).await?; } Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { // ... (same test block from Step 2) } ``` - [ ] **Step 5: Run tests to confirm pass** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -10 ``` Expected: both tests pass (401 without auth). - [ ] **Step 6: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -10 ``` If there are errors about `FollowRemoteRequest` still being used (e.g. in `federation.rs`), that's fine — Task 5 deletes that file. - [ ] **Step 7: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/api-types/src/requests.rs crates/presentation/src/handlers/notifications.rs git commit -m "refactor(api): notification state changes use PATCH" ``` --- ## Task 4: Users handler — content negotiation + lookup move **Files:** - Modify: `crates/presentation/src/handlers/users.rs` - [ ] **Step 1: Write failing tests** Add a `#[cfg(test)] mod tests` block at the bottom of `crates/presentation/src/handlers/users.rs`. The NoOpAuth/NoOpHasher pattern is the same as in Task 3. Add: ```rust #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{Request, header}, routing::get, Router, }; use domain::testing::TestStore; use std::sync::Arc; use tower::ServiceExt; // (copy NoOpAuth, NoOpHasher structs from federation.rs test module) fn make_state() -> crate::state::AppState { let store = Arc::new(TestStore::default()); crate::state::AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: Arc::new(NoOpAuth), hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), } } fn app() -> Router { Router::new() .route("/users/:username", get(get_user)) .route("/users/lookup", get(lookup_handler)) .with_state(make_state()) } #[tokio::test] async fn get_unknown_user_returns_404() { let resp = app() .oneshot(Request::builder().uri("/users/nobody").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), 404); } #[tokio::test] async fn get_user_with_ap_accept_calls_actor_json_returns_404_when_not_found() { // TestStore.actor_json returns NotFound, so AP requests to unknown users → 404 let resp = app() .oneshot( Request::builder() .uri("/users/nobody") .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 404); } #[tokio::test] async fn lookup_unknown_handle_returns_404() { let resp = app() .oneshot( Request::builder() .uri("/users/lookup?handle=%40alice%40example.com") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 404); } } ``` - [ ] **Step 2: Run to confirm tests compile but need implementation changes** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -20 ``` Expected: compile errors until we add `lookup_handler` to users.rs and modify `get_user`. - [ ] **Step 3: Update `users.rs`** Read the full `crates/presentation/src/handlers/users.rs`. **3a. Add new imports at the top:** ```rust use axum::http::{HeaderMap, header}; use axum::response::{IntoResponse, Response}; use api_types::responses::RemoteActorResponse; ``` **3b. Replace the `get_user` handler** (currently returns `Result, ApiError>`) with: ```rust pub async fn get_user( State(s): State, Path(username): Path, OptionalAuthUser(viewer): OptionalAuthUser, headers: HeaderMap, ) -> Result { let user = get_user_by_username(&*s.users, &username).await?; let accept = headers .get(header::ACCEPT) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if accept.contains("application/activity+json") { let json = s.federation.actor_json(&user.id).await?; Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()) } else { let is_followed = if let Some(viewer_id) = viewer { s.follows.find(&viewer_id, &user.id).await?.is_some() } else { false }; let mut resp = to_user_response(&user); resp.is_followed_by_viewer = is_followed; Ok(Json(resp).into_response()) } } ``` **3c. Rename `get_me_following_list` → `get_me_following`** (just the function name — update it in place): Find `pub async fn get_me_following_list` and rename to `pub async fn get_me_following`. **3d. Add `LookupQuery` and `lookup_handler` from `federation.rs`:** ```rust #[derive(serde::Deserialize)] pub struct LookupQuery { pub handle: String, } pub async fn lookup_handler( State(s): State, Query(q): Query, ) -> Result, ApiError> { let actor = s.federation.lookup_actor(&q.handle).await?; Ok(Json(RemoteActorResponse { handle: actor.handle, display_name: actor.display_name, avatar_url: actor.avatar_url, url: actor.url, })) } ``` - [ ] **Step 4: Run tests to confirm pass** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -10 ``` Expected: all 3 tests pass. - [ ] **Step 5: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10 ``` There will be errors about `federation.rs` still defining `lookup_handler` (duplicate) — that's resolved in Task 5 when we delete `federation.rs`. For now, just ensure `users.rs` itself compiles. - [ ] **Step 6: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/presentation/src/handlers/users.rs git commit -m "refactor(users): content negotiation at GET /users/{username}; move lookup handler" ``` --- ## Task 5: Social handler cleanup + delete `federation.rs` **Files:** - Modify: `crates/presentation/src/handlers/social.rs` - Delete: `crates/presentation/src/handlers/federation.rs` - Modify: `crates/presentation/src/handlers/mod.rs` - [ ] **Step 1: Write failing tests for unified follow** Add a `#[cfg(test)] mod tests` block at the bottom of `crates/presentation/src/handlers/social.rs`: ```rust #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::Request, routing::{delete, post}, Router, }; use domain::testing::TestStore; use std::sync::Arc; use tower::ServiceExt; // (copy NoOpAuth, NoOpHasher structs from federation.rs test module) fn make_state() -> crate::state::AppState { let store = Arc::new(TestStore::default()); crate::state::AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: Arc::new(NoOpAuth), hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), } } fn app() -> Router { Router::new() .route("/users/:username/follow", post(post_follow).delete(delete_follow)) .with_state(make_state()) } #[tokio::test] async fn follow_without_auth_returns_401() { let resp = app() .oneshot(Request::builder().method("POST").uri("/users/alice/follow").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), 401); } #[tokio::test] async fn unfollow_remote_handle_without_auth_returns_401() { let resp = app() .oneshot(Request::builder().method("DELETE").uri("/users/alice@example.com/follow").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), 401); } } ``` - [ ] **Step 2: Run to see compile state** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -15 ``` - [ ] **Step 3: Update `post_follow` to unify local and remote follows** In `crates/presentation/src/handlers/social.rs`, replace `post_follow` with: ```rust #[utoipa::path( post, path = "/users/{username}/follow", params(("username" = String, Path, description = "Username or user@domain handle")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])) )] pub async fn post_follow( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { if username.contains('@') { s.federation.follow_remote(&uid, &username).await?; } else { let target = get_user_by_username(&*s.users, &username).await?; follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; } Ok(StatusCode::NO_CONTENT) } ``` - [ ] **Step 4: Update `delete_follow` to reject remote handles** Replace `delete_follow` with: ```rust #[utoipa::path( delete, path = "/users/{username}/follow", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])) )] pub async fn delete_follow( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { if username.contains('@') { return Err(ApiError::BadRequest("remote unfollow not yet supported".into())); } let target = get_user_by_username(&*s.users, &username).await?; unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } ``` - [ ] **Step 5: Fix `{id}` → `{username}` in OpenAPI annotations for block handlers** In `social.rs`, update the `#[utoipa::path]` annotations on `post_block` and `delete_block`: - Change `path = "/users/{id}/block"` → `path = "/users/{username}/block"` - Change `("id" = uuid::Uuid, Path, description = "User ID")` → `("username" = String, Path, description = "Username")` Same for `post_follow` and `delete_follow` (already done in steps above). - [ ] **Step 6: Delete `federation.rs` and update `mod.rs`** Delete the file: ```bash rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs ``` In `crates/presentation/src/handlers/mod.rs`, remove the line: ```rust pub mod federation; ``` - [ ] **Step 7: Run tests** ```bash cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -10 ``` Expected: both tests pass (401 without auth). - [ ] **Step 8: Compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10 ``` Expected: no errors (all `federation::` references removed from routes in next task — routes.rs will fail until Task 6). - [ ] **Step 9: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/presentation/src/handlers/social.rs \ crates/presentation/src/handlers/mod.rs git rm crates/presentation/src/handlers/federation.rs git commit -m "refactor(social): unified follow handler; remove federation handler module" ``` --- ## Task 6: Routes + bootstrap **Files:** - Modify: `crates/presentation/src/routes.rs` - Modify: `crates/bootstrap/src/main.rs` - [ ] **Step 1: Replace `routes.rs` with the cleaned-up route table** Read `crates/presentation/src/routes.rs` first. Replace the full `api_routes` builder chain with: ```rust pub fn router() -> Router { let api_routes = Router::new() // health .route("/health", get(health::health_handler)) // auth .route("/auth/register", post(auth::post_register)) .route("/auth/login", post(auth::post_login)) // users — static before parameterised .route("/users", get(users::get_users)) .route("/users/count", get(users::get_user_count)) .route("/users/lookup", get(users::lookup_handler)) .route( "/users/me", get(users::get_me).patch(users::patch_profile), ) .route("/users/me/following", get(users::get_me_following)) .route("/users/me/top-friends", put(social::put_top_friends)) .route("/users/{username}", get(users::get_user)) .route( "/users/{username}/top-friends", get(social::get_top_friends_handler), ) .route( "/users/{username}/follow", post(social::post_follow).delete(social::delete_follow), ) .route( "/users/{username}/block", post(social::post_block).delete(social::delete_block), ) .route( "/users/{username}/followers", get(feed::get_followers_handler), ) .route( "/users/{username}/following", get(feed::get_following_handler), ) .route( "/users/{username}/thoughts", get(feed::user_thoughts_handler), ) // thoughts .route("/thoughts", post(thoughts::post_thought)) .route( "/thoughts/{id}", get(thoughts::get_thought_handler) .patch(thoughts::patch_thought) .delete(thoughts::delete_thought_handler), ) .route("/thoughts/{id}/thread", get(thoughts::get_thread_handler)) // likes & boosts .route( "/thoughts/{id}/like", post(social::post_like).delete(social::delete_like), ) .route( "/thoughts/{id}/boost", post(social::post_boost).delete(social::delete_boost), ) // feeds .route("/feed", get(feed::home_feed)) .route("/feed/public", get(feed::public_feed)) .route("/search", get(feed::search_handler)) .route("/tags/popular", get(feed::get_popular_tags)) .route("/tags/{name}", get(feed::tag_thoughts_handler)) // notifications .route( "/notifications", get(notifications::list_notifications).patch(notifications::mark_all_read), ) .route( "/notifications/{id}", patch(notifications::mark_notification_read), ) // api keys .route( "/api-keys", get(api_keys::get_api_keys).post(api_keys::post_api_key), ) .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)); openapi::serve(api_routes) } ``` Make sure `patch` is imported: `use axum::routing::{delete, get, patch, post, put};`. - [ ] **Step 2: Remove `/users/{username}` from the AP router in `main.rs`** Read `crates/bootstrap/src/main.rs`. In the `ap_router` builder, remove this line: ```rust .route("/users/{username}", axum::routing::get(actor_handler)) ``` Also remove the `actor_handler` import from `activitypub_base` if it's no longer used anywhere in `main.rs`: ```rust use activitypub_base::{ actor_handler::actor_handler, // ← remove this line followers_handler::{followers_handler, following_handler}, ... }; ``` - [ ] **Step 3: Full compile check** ```bash cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -15 ``` Expected: no errors. If `actor_handler` is still imported but unused, remove it. - [ ] **Step 4: Run all tests** ```bash cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10 ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash cd /mnt/drive/dev/thoughts git add crates/presentation/src/routes.rs crates/bootstrap/src/main.rs git commit -m "refactor(routes): clean RESTful route table; content negotiation at /users/{username}" ``` --- ## Task 7: Frontend — `api.ts` + `remote-user-card.tsx` **Files:** - Modify: `thoughts-frontend/lib/api.ts` - Modify: `thoughts-frontend/components/remote-user-card.tsx` - [ ] **Step 1: Update all changed URLs and methods in `api.ts`** Read `thoughts-frontend/lib/api.ts`. Make these targeted edits: **`getUserProfile`** — change URL: ```typescript export const getUserProfile = (username: string, token: string | null) => apiFetch(`/users/${username}`, {}, UserSchema, token); ``` **`getFollowersList`** — change URL: ```typescript export const getFollowersList = (username: string, token: string | null) => apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); ``` **`getFollowingList`** — change URL: ```typescript export const getFollowingList = (username: string, token: string | null) => apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); ``` **`getMeFollowingList`** — change URL: ```typescript export const getMeFollowingList = (token: string) => apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token); ``` **`lookupRemoteActor`** — change URL: ```typescript export const lookupRemoteActor = (handle: string, token: string | null) => apiFetch( `/users/lookup?handle=${encodeURIComponent(handle)}`, {}, RemoteActorSchema, token ); ``` **Delete `followRemoteUser`** — remove this entire function (unified follow now uses `followUser` with the full `user@domain` handle): ```typescript // DELETE this: export const followRemoteUser = (handle: string, token: string) => apiFetch( `/federation/follow`, { method: "POST", body: JSON.stringify({ handle }) }, z.null(), token ); ``` **Add `markNotificationRead`**: ```typescript export const markNotificationRead = (id: string, token: string) => apiFetch( `/notifications/${id}`, { method: "PATCH", body: JSON.stringify({ read: true }) }, z.null(), token ); ``` **Add `markAllNotificationsRead`**: ```typescript export const markAllNotificationsRead = (token: string) => apiFetch( "/notifications", { method: "PATCH", body: JSON.stringify({ read: true }) }, z.null(), token ); ``` - [ ] **Step 2: Update `remote-user-card.tsx`** Read `thoughts-frontend/components/remote-user-card.tsx`. Change the follow button's action from `followRemoteUser` to `followUser`: Replace: ```typescript import { followRemoteUser, RemoteActor } from "@/lib/api"; ``` With: ```typescript import { followUser, RemoteActor } from "@/lib/api"; ``` Replace: ```typescript await followRemoteUser(actor.handle, token); ``` With: ```typescript await followUser(actor.handle, token); ``` This works because `actor.handle` is now the full `user@domain` format (e.g. `gabrielkaszewski@mastodon.social`) from the fixed `lookup_actor`, and `followUser` calls `POST /users/gabrielkaszewski@mastodon.social/follow`, which the unified handler detects as a remote follow. - [ ] **Step 3: Type-check** ```bash cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20 ``` Expected: no errors. If any page references `followRemoteUser`, update it to `followUser`. - [ ] **Step 4: Commit** ```bash cd /mnt/drive/dev/thoughts git add thoughts-frontend/lib/api.ts thoughts-frontend/components/remote-user-card.tsx git commit -m "refactor(frontend): update API client to match cleaned REST routes" ``` --- ## Self-Review **Spec coverage:** - ✅ `GET /users/{username}` content negotiation — Tasks 1, 2, 4, 6 - ✅ `GET /users/lookup` moved from `/federation/lookup` — Tasks 4, 6 - ✅ `POST /users/{username}/follow` unified — Task 5, 6 - ✅ `DELETE /users/{username}/follow` 400 for remote — Task 5 - ✅ `{id}` → `{username}` param rename in follow/block — Tasks 5, 6 - ✅ `followers`/`following` route rename — Task 6 - ✅ `me/following` rename — Tasks 4, 6 - ✅ `PATCH /notifications/{id}` — Tasks 3, 6 - ✅ `PATCH /notifications` bulk — Tasks 3, 6 - ✅ `PUT /users/me` removed — Task 6 - ✅ `POST /federation/follow` removed — Tasks 5, 6 - ✅ Frontend api.ts updates — Task 7 - ✅ `remote-user-card.tsx` followUser — Task 7 - ✅ Handle format fix (`user@domain`) in `lookup_actor` — Task 2 **Placeholder scan:** None found. **Type consistency:** - `actor_json(&self, user_id: &UserId)` defined in Task 1, implemented in Task 2, called in Task 4 ✅ - `get_me_following` renamed in Task 4, referenced in Task 6 routes ✅ - `lookup_handler` defined in Task 4 (users.rs), referenced in Task 6 routes as `users::lookup_handler` ✅ - `NotificationUpdateRequest` defined in Task 3 (api-types), used in Task 3 (notifications.rs) ✅ - `followUser(actor.handle, token)` — `actor.handle` is full `user@domain` after Task 2 fix ✅