diff --git a/docs/superpowers/specs/2026-05-15-feedentry-decoupling-design.md b/docs/superpowers/specs/2026-05-15-feedentry-decoupling-design.md new file mode 100644 index 0000000..a746724 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-feedentry-decoupling-design.md @@ -0,0 +1,80 @@ +# FeedEntry Decoupling Design + +**Goal:** Fix search viewer context (functional), restructure `FeedEntry` for clarity (structural), and make viewer presence explicit via `Option` (type-safe). + +**Priority:** C (search fix) → B (struct clarity) → A (type safety). All three land in one pass. + +--- + +## Data Model + +Replace flat fields on `FeedEntry` with two named sub-structs in `crates/domain/src/models/feed.rs`: + +```rust +#[derive(Debug, Clone)] +pub struct EngagementStats { + pub like_count: i64, + pub boost_count: i64, + pub reply_count: i64, +} + +#[derive(Debug, Clone)] +pub struct ViewerContext { + pub liked: bool, + pub boosted: bool, +} + +#[derive(Debug, Clone)] +pub struct FeedEntry { + pub thought: Thought, + pub author: User, + pub stats: EngagementStats, + pub viewer: Option, // None when no authenticated viewer +} +``` + +`viewer: None` means the request was anonymous or viewer state is unavailable (e.g. search without auth). `viewer: Some(ViewerContext { liked: false, boosted: false })` means a viewer is known and they have not liked or boosted the thought. These two states are now distinct at the type level. + +--- + +## Search Adapter Fix + +`SearchPort::search_thoughts` already accepts `viewer_id: Option<&UserId>` but `postgres-search/src/lib.rs` ignores it, always hardcoding `false` for viewer fields. + +Fix: conditionally inject EXISTS subqueries into the search SQL, identical to the pattern used in `postgres/src/feed.rs`: + +```sql +-- viewer_id = None (anonymous) +false AS liked_by_viewer, +false AS boosted_by_viewer + +-- viewer_id = Some(uid) +EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer, +EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer +``` + +The `FeedRow` struct in postgres-search already has `liked_by_viewer: bool` and `boosted_by_viewer: bool` columns — they just need to be populated correctly. No schema change required. + +--- + +## Callsite Migration + +| File | Change | +|---|---| +| `crates/domain/src/models/feed.rs` | Replace flat stats/viewer fields with `EngagementStats` and `Option` | +| `crates/adapters/postgres/src/feed.rs` — `row_to_entry` | Construct `EngagementStats { ... }` and `viewer: Some/None` based on `FeedRow` | +| `crates/adapters/postgres-search/src/lib.rs` — `row_to_entry` + SQL | Fix SQL to use viewer_id; build `Option` from result | +| `crates/presentation/src/handlers/feed.rs` — `to_thought_response` | `e.stats.like_count`, `e.viewer.as_ref().map(|v| v.liked).unwrap_or(false)` | +| `crates/domain/src/testing.rs` — `TestStore` feed impl | Build `FeedEntry` with `stats:` and `viewer:` fields | + +`ThoughtResponse` in `api-types/src/responses.rs` keeps `liked_by_viewer: bool` and `boosted_by_viewer: bool` — the wire format is unchanged. `viewer: None` serialises as `false` in `to_thought_response`. + +--- + +## What Does Not Change + +- `FeedRepository` port signatures (still returns `Paginated`) +- HTTP response shape (`ThoughtResponse`) +- Database schema +- Pagination, filtering, or query logic +- Any code path that doesn't touch `FeedEntry` fields directly