Files
comparaison/docs/plans/2026-04-26-feed-profile-auth.md

12 KiB
Raw Blame History

ComparAIson v0.2v0.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
  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

// 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<number>`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<Record<string, string[]>>(
    (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

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

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

// 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<number>`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<Record<string, string[]>>(
    (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

// 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<number>`count(*)`,
      totalViews: sql<number>`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

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:

"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<ExploreComparison[]>([])
  const [total, setTotal] = useState(0)
  const [page, setPage] = useState(1)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(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

<Button 
  variant="outline" 
  className="gap-2"
  onClick={() => {
    const nextPage = page + 1
    setPage(nextPage)
    fetchComparisons(searchQuery, nextPage)
  }}
  disabled={loading || !hasMore}
>
  {loading ? "Loading..." : "Load More"}
</Button>

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