diff --git a/docs/plans/2026-04-26-feed-profile-auth.md b/docs/plans/2026-04-26-feed-profile-auth.md new file mode 100644 index 0000000..fd95584 --- /dev/null +++ b/docs/plans/2026-04-26-feed-profile-auth.md @@ -0,0 +1,458 @@ +# ComparAIson v0.2–v0.4 Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Replace all mock data with real API-backed pages, wire auth into the comparison flow, and add search + management features. + +**Architecture:** Next.js App Router API routes backed by existing Drizzle ORM schema. Server actions for simple reads, REST endpoints for complex queries. Better Auth sessions for user identity. + +**Tech Stack:** Next.js 15, Drizzle ORM, PostgreSQL, Better Auth, shadcn/ui, Recharts + +--- + +## Dependency Graph + +``` +v0.2 Feed & Profile (issues #1-#4) + #1 API: Public feed endpoint ──────┐ + #2 API: User comparisons endpoint ─┤ + ├──> #3 Wire Explore page + ├──> #4 Wire Profile page + +v0.3 Auth Integration (issue #5) + #5 Associate comparisons with users + +v0.4 Search & Polish (issues #6-#8) + #6 Functional search ──> depends on #3 + #7 Delete/toggle visibility ──> depends on #5 + #8 Loading/error states ──> can start anytime +``` + +## Recommended Execution Order + +1. **#1** — API: Public feed endpoint (no dependencies) +2. **#2** — API: User comparisons endpoint (no dependencies) +3. **#3** — Wire Explore page (depends on #1) +4. **#4** — Wire Profile page (depends on #2) +5. **#5** — Associate comparisons with users (foundational for v0.3) +6. **#6** — Functional search (depends on #3) +7. **#7** — Delete/toggle visibility (depends on #5) +8. **#8** — Loading/error states (independent, can parallelize) + +--- + +## Phase 1: v0.2 — Feed & Profile (Milestone #5) + +### Task 1.1: Create GET /api/comparisons route (#1) + +**Objective:** Paginated public feed API endpoint. + +**Files:** +- Create: `src/app/api/comparisons/route.ts` + +**Step 1: Create the route file** + +```typescript +// src/app/api/comparisons/route.ts +import { db } from "@/lib/db"; +import { comparisons, comparisonItems } from "@/lib/db/schema"; +import { eq, and, ilike, desc, sql } from "drizzle-orm"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const page = Math.max(1, Number(searchParams.get("page")) || 1); + const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20)); + const search = searchParams.get("search")?.trim() || ""; + const offset = (page - 1) * limit; + + const conditions = [ + eq(comparisons.isPublic, true), + eq(comparisons.status, "completed"), + ]; + + if (search) { + conditions.push(ilike(comparisons.title, `%${search}%`)); + } + + const where = and(...conditions); + + const [rows, countResult] = await Promise.all([ + db + .select() + .from(comparisons) + .where(where) + .orderBy(desc(comparisons.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(comparisons) + .where(where), + ]); + + const total = countResult[0]?.count ?? 0; + + // Fetch item names for each comparison + const comparisonIds = rows.map((r) => r.id); + const items = comparisonIds.length + ? await db + .select({ + comparisonId: comparisonItems.comparisonId, + name: comparisonItems.name, + }) + .from(comparisonItems) + .where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`) + : []; + + const itemsByComparison = items.reduce>( + (acc, item) => { + if (!acc[item.comparisonId]) acc[item.comparisonId] = []; + acc[item.comparisonId].push(item.name); + return acc; + }, + {} + ); + + return Response.json({ + comparisons: rows.map((row) => ({ + id: row.id, + title: row.title, + summary: row.summary?.slice(0, 200) || "", + slug: row.slug, + tags: row.tags || [], + items: itemsByComparison[row.id] || [], + viewCount: row.viewCount, + createdAt: row.createdAt.toISOString(), + })), + total, + page, + limit, + }); +} +``` + +**Step 2: Create GET /api/comparisons/[slug]/route.ts** + +```typescript +// src/app/api/comparisons/[slug]/route.ts +import { db } from "@/lib/db"; +import { comparisons, comparisonItems } from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { getComparison } from "@/app/actions/comparison"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + + // Increment view count + await db + .update(comparisons) + .set({ viewCount: sql`${comparisons.viewCount} + 1` }) + .where(eq(comparisons.slug, slug)); + + const data = await getComparison(slug); + if (!data) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + return Response.json(data); +} +``` + +**Step 3: Verify** +```bash +npm run build # check for type errors +``` + +--- + +### Task 1.2: Create GET /api/user/comparisons and /api/user/stats (#2) + +**Objective:** User-specific comparison listing and stats. + +**Files:** +- Create: `src/app/api/user/comparisons/route.ts` +- Create: `src/app/api/user/stats/route.ts` + +**Step 1: User comparisons endpoint** + +```typescript +// src/app/api/user/comparisons/route.ts +import { db } from "@/lib/db"; +import { comparisons, comparisonItems } from "@/lib/db/schema"; +import { eq, desc, sql } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const page = Math.max(1, Number(searchParams.get("page")) || 1); + const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20)); + const offset = (page - 1) * limit; + + const userId = session.user.id; + + const [rows, countResult] = await Promise.all([ + db + .select() + .from(comparisons) + .where(eq(comparisons.userId, userId)) + .orderBy(desc(comparisons.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(comparisons) + .where(eq(comparisons.userId, userId)), + ]); + + const total = countResult[0]?.count ?? 0; + + const comparisonIds = rows.map((r) => r.id); + const items = comparisonIds.length + ? await db + .select({ + comparisonId: comparisonItems.comparisonId, + name: comparisonItems.name, + }) + .from(comparisonItems) + .where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`) + : []; + + const itemsByComparison = items.reduce>( + (acc, item) => { + if (!acc[item.comparisonId]) acc[item.comparisonId] = []; + acc[item.comparisonId].push(item.name); + return acc; + }, + {} + ); + + return Response.json({ + comparisons: rows.map((row) => ({ + id: row.id, + title: row.title, + slug: row.slug, + tags: row.tags || [], + items: itemsByComparison[row.id] || [], + status: row.status, + isPublic: row.isPublic, + viewCount: row.viewCount, + createdAt: row.createdAt.toISOString(), + })), + total, + page, + }); +} +``` + +**Step 2: User stats endpoint** + +```typescript +// src/app/api/user/stats/route.ts +import { db } from "@/lib/db"; +import { comparisons } from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + const stats = await db + .select({ + totalComparisons: sql`count(*)`, + totalViews: sql`coalesce(sum(${comparisons.viewCount}), 0)`, + }) + .from(comparisons) + .where(eq(comparisons.userId, userId)); + + return Response.json({ + totalComparisons: stats[0]?.totalComparisons ?? 0, + totalViews: stats[0]?.totalViews ?? 0, + }); +} +``` + +**Step 3: Verify** +```bash +npm run build +``` + +--- + +### Task 1.3: Wire Explore page (#3) + +**Objective:** Replace mock data with real API calls. + +**Files:** +- Modify: `src/app/(main)/explore/page.tsx` + +**Step 1: Replace mock data with fetch logic** + +Replace the entire `allComparisons` array and `categories` with: + +```typescript +"use client" + +import { useState, useEffect, useCallback } from "react" +// ... existing imports ... + +interface ExploreComparison { + id: string + title: string + summary: string + slug: string + tags: string[] + items: string[] + viewCount: number + createdAt: string +} + +export default function ExplorePage() { + const [searchQuery, setSearchQuery] = useState("") + const [comparisons, setComparisons] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const limit = 20 + + const fetchComparisons = useCallback(async (search: string, pageNum: number) => { + setLoading(true) + setError(null) + try { + const params = new URLSearchParams({ + page: String(pageNum), + limit: String(limit), + }) + if (search) params.set("search", search) + const res = await fetch(`/api/comparisons?${params}`) + if (!res.ok) throw new Error("Failed to fetch") + const data = await res.json() + if (pageNum === 1) { + setComparisons(data.comparisons) + } else { + setComparisons((prev) => [...prev, ...data.comparisons]) + } + setTotal(data.total) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load comparisons") + } finally { + setLoading(false) + } + }, []) + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + setPage(1) + fetchComparisons(searchQuery, 1) + }, 300) + return () => clearTimeout(timer) + }, [searchQuery, fetchComparisons]) + + // Initial load + useEffect(() => { + fetchComparisons("", 1) + }, [fetchComparisons]) + + // Extract unique tags from loaded comparisons + const categories = ["All", ...new Set(comparisons.flatMap((c) => c.tags))] + const [selectedCategory, setSelectedCategory] = useState("All") + + const filtered = selectedCategory === "All" + ? comparisons + : comparisons.filter((c) => c.tags.includes(selectedCategory)) + + const hasMore = comparisons.length < total + + // ... render (replace allComparisons references with `filtered`) + // Change Link href from `/compare/${comparison.id}` to `/compare/${comparison.slug}` +} +``` + +**Step 2: Update "Load More" button** + +```tsx + +``` + +--- + +### Task 1.4: Wire Profile page (#4) + +**Objective:** Replace mock data with real user data. + +**Files:** +- Modify: `src/app/(main)/profile/page.tsx` + +Similar pattern to Explore — replace mock arrays with `useEffect` + `fetch` to `/api/user/comparisons` and `/api/user/stats`. Show sign-in CTA if no session. + +--- + +## Phase 2: v0.3 — Auth Integration (Milestone #6) + +### Task 2.1: Associate comparisons with users (#5) + +**Objective:** Set userId on comparison creation. + +**Files:** +- Modify: `src/app/api/compare/route.ts` +- Modify: `src/app/actions/comparison.ts` + +Read auth session from headers, extract userId, pass to insert. Add auth check to compare page. + +--- + +## Phase 3: v0.4 — Search & Polish (Milestone #7) + +### Task 3.1: Functional search (#6) + +Navigate header search to `/explore?search=...`. Read URL params in Explore page. + +### Task 3.2: Delete/toggle visibility (#7) + +Add `DELETE` and `PATCH` routes. Add dropdown menus to profile comparison cards. + +### Task 3.3: Loading/error states (#8) + +Add error rendering in compare page, error boundary, toast notifications. + +--- + +## Gitea Issues Summary + +| # | Title | Labels | Milestone | +|---|-------|--------|-----------| +| 1 | API: Public feed endpoint | feature, backend | v0.2 | +| 2 | API: User comparisons endpoint | feature, backend | v0.2 | +| 3 | Wire Explore page | feature, frontend | v0.2 | +| 4 | Wire Profile page | feature, frontend | v0.2 | +| 5 | Associate comparisons with users | bug, backend | v0.3 | +| 6 | Functional search | feature, frontend | v0.4 | +| 7 | Delete/toggle visibility | feature, frontend, backend | v0.4 | +| 8 | Loading/error states | improvement, frontend | v0.4 |