2 Commits

Author SHA1 Message Date
Christopher Mayor
494dcb91fa fix: remove duplicate users table definition in schema
The users table was defined twice with conflicting field orderings
(timestamp vs boolean for emailVerified, different default placements).
Kept the cleaner definition and removed the duplicate.
2026-04-26 15:55:47 -07:00
Christopher Mayor
3c5df6a74c docs: add v0.2-v0.4 implementation plan for feed, profile, auth, and search 2026-04-26 01:16:35 -07:00
2 changed files with 461 additions and 13 deletions

View File

@@ -0,0 +1,458 @@
# 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
```
## 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<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**
```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<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**
```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<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**
```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<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**
```tsx
<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 |

View File

@@ -14,10 +14,10 @@ export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified", { withTimezone: true }),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [
@@ -26,16 +26,6 @@ export const comparisonStatusEnum = pgEnum("comparison_status", [
"failed",
]);
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")