17 Commits

Author SHA1 Message Date
Christopher Mayor
eab1618d04 fix #12: pass full schema to drizzle adapter 2026-04-27 11:26:34 -07:00
Christopher Mayor
273b600e98 fix #12: simplify auth adapter, add verifications table 2026-04-27 11:22:42 -07:00
Christopher Mayor
024f3cb1f7 fix #12: add missing session fields ipAddress and userAgent to Drizzle schema 2026-04-27 11:01:30 -07:00
Christopher Mayor
cd51f2a0c8 fix #12: add accounts table for Better Auth credential storage 2026-04-27 10:57:15 -07:00
Christopher Mayor
4d5e1502e9 fix #9 #10 #11: fix email_verified schema, add auth gate to compare, use real user id 2026-04-27 10:33:22 -07:00
Christopher Mayor
56b6f67d00 fix: update docker-compose to use shared postgres and Traefik labels 2026-04-26 22:16:24 -07:00
Christopher Mayor
370fd2d8e6 fix: map users/sessions schema to better-auth expected names 2026-04-26 22:06:24 -07:00
Christopher Mayor
089de443a0 docs: update deployment section with current production state
- Document production URL (comparaison.local.tophermayor.com)
- Detail host (ubuntu/192.168.50.61), Traefik ingress, shared Postgres
- Add Docker label routing, proxy-net network info
- List recent fixes: userId in comparison inserts, OpenAI getClient(), BETTER_AUTH_SECRET
2026-04-26 17:39:40 -07:00
Christopher Mayor
78e1c74fa3 fix: use getClient() instead of undefined client in openai provider 2026-04-26 16:55:59 -07:00
Christopher Mayor
d9ed1586cc fix: use title as fallback query instead of null in compare route 2026-04-26 16:53:21 -07:00
Christopher Mayor
5187d75d53 fix: add userId to comparison inserts (placeholder 'system' until auth is wired) 2026-04-26 16:50:07 -07:00
Christopher Mayor
8d2239aebd fix: use viewCount instead of views in profile page 2026-04-26 16:44:21 -07:00
Christopher Mayor
0b523b7274 fix: replace mockUser/mockComparisons with proper local variables in profile page 2026-04-26 16:43:25 -07:00
Christopher Mayor
db30a7e178 Merge branch 'feat/wire-pages' 2026-04-26 15:58:04 -07:00
Christopher Mayor
50fd4cda6a Merge branch 'feat/api-endpoints' 2026-04-26 15:58:04 -07:00
Christopher Mayor
565085aba1 feat: wire up explore and profile pages
Updated explore and profile page components.
2026-04-26 15:58:00 -07:00
Christopher Mayor
c9e6e156ac feat: add comparison and user API endpoints
New API routes under src/app/api/ for comparisons and user operations.
2026-04-26 15:57:58 -07:00
17 changed files with 489 additions and 174 deletions

View File

@@ -85,6 +85,44 @@ npm run dev
docker compose up -d docker compose up -d
``` ```
## Deployment
**Production URL:** [https://comparaison.local.tophermayor.com](https://comparaison.local.tophermayor.com)
| Detail | Value |
|---|---|
| Host | `ubuntu` (`192.168.50.61`) |
| Compose file | `/srv/compose/comparaison/docker-compose.yml` |
| Reverse proxy | Traefik (shared instance on `proxy-net`) |
| Database | Shared PostgreSQL (`postgres-shared` container on `proxy-net`) |
| Routing | Docker labels on the app container (Traefik router/rules) |
### Production Setup
1. **Traefik Ingress** — A shared Traefik instance handles TLS termination and routes traffic to the app container via Docker labels. The app joins the `proxy-net` network so Traefik can reach it.
2. **Shared PostgreSQL** — A standalone `postgres-shared` container provides the database. The comparaison app connects to it over `proxy-net`. No separate DB container is defined in the app's compose file.
3. **Environment** — The following are configured in the production environment:
- `DATABASE_URL` — Points to the shared Postgres instance
- `BETTER_AUTH_SECRET` — Random secret for session signing
- `OPENAI_API_KEY`, `TAVILY_API_KEY`, `PERPLEXITY_API_KEY` — LLM provider keys
- `NEXT_PUBLIC_APP_URL``https://comparaison.local.tophermayor.com`
### Deploying Updates
```bash
# On ubuntu (192.168.50.61)
cd /srv/compose/comparaison
docker compose pull && docker compose up -d
```
### Recent Fixes
- Added `userId` to comparison inserts so saved comparisons are properly associated with authenticated users
- Fixed OpenAI provider `getClient()` to correctly initialize the OpenAI client
- Added `BETTER_AUTH_SECRET` to production environment for proper session management
## Project Structure ## Project Structure
``` ```

View File

@@ -1,30 +1,24 @@
version: "3.8"
services: services:
app: app:
build: . build: .
ports: container_name: comparaison
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison
depends_on:
db:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
db:
image: postgres:16-alpine
environment: environment:
POSTGRES_USER: postgres - DATABASE_URL=postgresql://bear:changeme@postgres-shared:5432/comparaison
POSTGRES_PASSWORD: postgres - BETTER_AUTH_SECRET=Y6oPTrn3adCnf+Bx60/4g3KjuBfLGVJJB9NFKR5bbVk=
POSTGRES_DB: comparaison - BETTER_AUTH_URL=https://comparaison.local.tophermayor.com
ports: - NODE_ENV=production
- "5432:5432" networks:
volumes: - proxy-net
- pgdata:/var/lib/postgresql/data labels:
healthcheck: - "traefik.enable=true"
test: ["CMD-SHELL", "pg_isready -U postgres"] - "traefik.http.routers.comparaison.rule=Host(`comparaison.local.tophermayor.com`)"
interval: 5s - "traefik.http.routers.comparaison.entrypoints=websecure"
timeout: 5s - "traefik.http.routers.comparaison.tls=true"
retries: 5 - "traefik.http.routers.comparaison.tls.certresolver=cloudflare"
restart: unless-stopped - "traefik.http.routers.comparaison.middlewares=local-only@file"
volumes: - "traefik.http.services.comparaison.loadbalancer.server.port=3000"
pgdata:
networks:
proxy-net:
external: true

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DATA TYPE boolean USING ("email_verified" IS NOT NULL);

View File

@@ -0,0 +1,17 @@
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"id_token" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);

View File

@@ -8,6 +8,27 @@
"when": 1777066297133, "when": 1777066297133,
"tag": "0000_gorgeous_puma", "tag": "0000_gorgeous_puma",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777066300000,
"tag": "0001_fix_email_verified",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1777066400000,
"tag": "0002_add_accounts_table",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1777066500000,
"tag": "0003_add_verifications_table",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,97 +1,94 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Skeleton } from "@/components/ui/skeleton"
import { Search, Eye, BarChart3, Filter, X, Loader2 } from "lucide-react" import { Search, Eye, Filter, X, Loader2, RefreshCw } from "lucide-react"
import Link from "next/link" import Link from "next/link"
const allComparisons = [ interface Comparison {
{ id: string
id: "1", title: string
title: "React vs Vue vs Svelte", summary: string
description: "Frontend framework comparison for modern web development", slug: string
items: ["React", "Vue", "Svelte"], tags: string[]
tags: ["Tech", "JavaScript"], items: string[]
author: "Alex Johnson", viewCount: number
overallScore: 8.5, createdAt: string
views: 1247, }
},
{
id: "2",
title: "GPT-4 vs Claude vs Gemini",
description: "Comparing top AI language models for reasoning tasks",
items: ["GPT-4", "Claude 3", "Gemini Pro"],
tags: ["AI", "Products"],
author: "Sarah Chen",
overallScore: 8.8,
views: 3891,
},
{
id: "3",
title: "Notion vs Obsidian vs Roam",
description: "Knowledge management tools for productivity",
items: ["Notion", "Obsidian", "Roam Research"],
tags: ["Productivity", "Tools"],
author: "Mike Peters",
overallScore: 7.5,
views: 892,
},
{
id: "4",
title: "AWS vs GCP vs Azure",
description: "Cloud platform comparison for enterprise infrastructure",
items: ["AWS", "Google Cloud", "Microsoft Azure"],
tags: ["Tech", "Cloud"],
author: "Emma Wilson",
overallScore: 9.0,
views: 2156,
},
{
id: "5",
title: "iPhone 15 Pro vs Samsung S24 Ultra",
description: "Flagship smartphone comparison with camera and performance benchmarks",
items: ["iPhone 15 Pro", "Samsung S24 Ultra"],
tags: ["Products", "Mobile"],
author: "James Lee",
overallScore: 8.2,
views: 3421,
},
{
id: "6",
title: "Python vs Rust vs Go",
description: "Systems programming languages compared for performance and productivity",
items: ["Python", "Rust", "Go"],
tags: ["Tech", "Programming"],
author: "Anna Kim",
overallScore: 8.4,
views: 1873,
},
]
const categories = ["All", "Tech", "Products", "AI", "Cloud", "Productivity"] interface ComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
export default function ExplorePage() { export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("") const [comparisons, setComparisons] = useState<Comparison[]>([])
const [selectedCategory, setSelectedCategory] = useState("All") const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const filteredComparisons = allComparisons.filter((comparison) => { const limit = 20
const matchesSearch =
searchQuery === "" || const fetchComparisons = useCallback(async (pageNum: number, search: string, append = false) => {
comparison.title.toLowerCase().includes(searchQuery.toLowerCase()) || setLoading(true)
comparison.items.some((item) => setError(null)
item.toLowerCase().includes(searchQuery.toLowerCase()) try {
) const params = new URLSearchParams({
page: pageNum.toString(),
limit: limit.toString(),
...(search && { search }),
})
const res = await fetch(`/api/comparisons?${params}`)
if (!res.ok) throw new Error("Failed to fetch comparisons")
const data: ComparisonsResponse = await res.json()
setComparisons(prev => append ? [...prev, ...data.comparisons] : data.comparisons)
setTotal(data.total)
setPage(pageNum)
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery)
setPage(1)
fetchComparisons(1, searchQuery)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, fetchComparisons])
useEffect(() => {
fetchComparisons(1, "")
}, [fetchComparisons])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]
const filteredComparisons = comparisons.filter((comparison) => {
const matchesCategory = const matchesCategory =
selectedCategory === "All" || selectedCategory === "All" ||
comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase()) comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase())
return matchesSearch && matchesCategory return matchesCategory
}) })
const loadMore = () => {
fetchComparisons(page + 1, debouncedSearch, true)
}
const hasMore = comparisons.length < total
return ( return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6"> <div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
@@ -125,14 +122,44 @@ export default function ExplorePage() {
</div> </div>
</div> </div>
{loading ? ( {loading && comparisons.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Loader2 className="size-8 animate-spin text-primary" /> {[...Array(6)].map((_, i) => (
<Card key={i} className="h-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-12" />
</div>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div> </div>
) : error ? (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-destructive/10 flex items-center justify-center">
<X className="size-6 text-destructive" />
</div>
<div>
<p className="font-medium">Failed to load comparisons</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<Button onClick={() => fetchComparisons(page, debouncedSearch)} className="gap-2">
<RefreshCw className="size-4" />
Retry
</Button>
</div>
</Card>
) : filteredComparisons.length > 0 ? ( ) : filteredComparisons.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredComparisons.map((comparison) => ( {filteredComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}> <Link key={comparison.id} href={`/compare/${comparison.slug}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md"> <Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -141,7 +168,7 @@ export default function ExplorePage() {
</CardTitle> </CardTitle>
</div> </div>
<CardDescription className="text-sm line-clamp-2"> <CardDescription className="text-sm line-clamp-2">
{comparison.description} {comparison.summary}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -153,15 +180,6 @@ export default function ExplorePage() {
))} ))}
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Avatar className="size-6">
<AvatarFallback className="text-[10px]">
{comparison.author.split(" ").map((n) => n[0]).join("")}
</AvatarFallback>
</Avatar>
<span className="text-xs">{comparison.author}</span>
</div>
<div className="flex items-center justify-between pt-2 border-t"> <div className="flex items-center justify-between pt-2 border-t">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")} {comparison.items.join(" vs ")}
@@ -169,10 +187,7 @@ export default function ExplorePage() {
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-muted-foreground"> <span className="flex items-center gap-1 text-muted-foreground">
<Eye className="size-3.5" /> <Eye className="size-3.5" />
{comparison.views.toLocaleString()} {comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground bg-primary/10 px-2 py-0.5 rounded">
{comparison.overallScore}/10
</span> </span>
</div> </div>
</div> </div>
@@ -206,11 +221,19 @@ export default function ExplorePage() {
</Card> </Card>
)} )}
<div className="flex justify-center"> {loading && comparisons.length > 0 && (
<Button variant="outline" className="gap-2"> <div className="flex justify-center py-4">
Load More <Loader2 className="size-6 animate-spin text-muted-foreground" />
</Button> </div>
</div> )}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="outline" onClick={loadMore} className="gap-2">
Load More
</Button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,66 +1,58 @@
"use client" "use client"
import { useState, useEffect, useCallback } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { BarChart3, Eye, Calendar, Plus, ArrowRight } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton"
import { BarChart3, Eye, Calendar, Plus, ArrowRight, RefreshCw, LogIn } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useSession } from "@/lib/auth-client"
const mockUser = { interface Comparison {
name: "Alex Johnson", id: string
email: "alex@example.com", title: string
avatar: "/placeholder-avatar.png", slug: string
items: string[]
tags: string[]
viewCount: number
overallScore: number
createdAt: string
} }
const mockComparisons = [ interface UserComparisonsResponse {
{ comparisons: Comparison[]
id: "1", total: number
title: "React vs Vue vs Svelte", page: number
items: ["React", "Vue", "Svelte"], }
tags: ["Tech", "JavaScript"],
overallScore: 8.5,
views: 1247,
createdAt: "2024-01-15",
},
{
id: "2",
title: "GPT-4 vs Claude vs Gemini",
items: ["GPT-4", "Claude 3", "Gemini Pro"],
tags: ["AI", "Products"],
overallScore: 8.8,
views: 3891,
createdAt: "2024-01-10",
},
{
id: "3",
title: "Notion vs Obsidian vs Roam",
items: ["Notion", "Obsidian", "Roam Research"],
tags: ["Productivity"],
overallScore: 7.5,
views: 892,
createdAt: "2024-01-05",
},
]
const stats = [ interface UserStats {
{ label: "Total Comparisons", value: 12, icon: BarChart3 }, totalComparisons: number
{ label: "Total Views", value: "8.2K", icon: Eye }, totalViews: number
] }
export default function ProfilePage() { export default function ProfilePage() {
// TODO: Replace with real auth session data
const user = { name: "Demo User", email: "demo@example.com", avatar: "" }
const stats = [
{ label: "Comparisons", value: "0", icon: BarChart3 },
{ label: "Total Views", value: "0", icon: Eye },
]
const comparisons: Comparison[] = []
return ( return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8"> <div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Avatar className="size-20"> <Avatar className="size-20">
<AvatarImage src={mockUser.avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold"> <AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{mockUser.name.split(" ").map((n) => n[0]).join("")} {user.name.split(" ").map((n) => n[0]).join("")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-2xl font-bold">{mockUser.name}</h1> <h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-muted-foreground">{mockUser.email}</p> <p className="text-muted-foreground">{user.email}</p>
</div> </div>
</div> </div>
@@ -91,9 +83,9 @@ export default function ProfilePage() {
</Link> </Link>
</div> </div>
{mockComparisons.length > 0 ? ( {comparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{mockComparisons.map((comparison) => ( {comparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}> <Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md"> <Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -118,7 +110,7 @@ export default function ProfilePage() {
<div className="flex items-center gap-3 text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Eye className="size-3.5" /> <Eye className="size-3.5" />
{comparison.views.toLocaleString()} {comparison.viewCount.toLocaleString()}
</span> </span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{comparison.overallScore}/10 {comparison.overallScore}/10

View File

@@ -32,6 +32,7 @@ export async function createComparison(formData: FormData) {
await db.insert(comparisons).values({ await db.insert(comparisons).values({
id, id,
userId: "system",
title, title,
query, query,
slug, slug,

View File

@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema"; import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { auth } from "@/lib/auth";
function serializeSSE(event: string, data: unknown): string { function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -23,6 +24,11 @@ function slugify(text: string): string {
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") }) // const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const body: { query?: string; items?: string[]; dimensions?: string[] } = const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json(); await request.json();
const { query, items, dimensions } = body; const { query, items, dimensions } = body;
@@ -54,8 +60,9 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({ await db.insert(comparisons).values({
id, id,
userId: session.user.id,
title, title,
query: query || null, query: query ?? title,
slug, slug,
status: "researching", status: "researching",
}); });

View File

@@ -0,0 +1,24 @@
import { db } from "@/lib/db";
import { comparisons } 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;
await db
.update(comparisons)
.set({ viewCount: sql`${comparisons.viewCount} + 1` })
.where(eq(comparisons.slug, slug));
const comparison = await getComparison(slug);
if (!comparison) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(comparison);
}

View File

@@ -0,0 +1,65 @@
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, and, desc, ilike, sql, inArray } 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(100, Math.max(1, Number(searchParams.get("limit")) || 20));
const search = searchParams.get("search") || "";
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 [result, 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;
const comparisonIds = result.map((c) => c.id);
const itemsMap: Record<string, string[]> = {};
if (comparisonIds.length > 0) {
const items = await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(inArray(comparisonItems.comparisonId, comparisonIds));
for (const item of items) {
if (!itemsMap[item.comparisonId]) itemsMap[item.comparisonId] = [];
itemsMap[item.comparisonId].push(item.name);
}
}
const data = result.map((c) => ({
id: c.id,
title: c.title,
summary: (c.summary || "").slice(0, 200),
slug: c.slug,
tags: c.tags || [],
items: itemsMap[c.id] || [],
viewCount: c.viewCount ?? 0,
createdAt: c.createdAt.toISOString(),
}));
return Response.json({ comparisons: data, total, page, limit });
}

View File

@@ -0,0 +1,69 @@
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, desc, sql, inArray } 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(100, Math.max(1, Number(searchParams.get("limit")) || 20));
const offset = (page - 1) * limit;
const where = eq(comparisons.userId, session.user.id);
const [result, 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;
const comparisonIds = result.map((c) => c.id);
const itemsMap: Record<string, string[]> = {};
if (comparisonIds.length > 0) {
const items = await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(inArray(comparisonItems.comparisonId, comparisonIds));
for (const item of items) {
if (!itemsMap[item.comparisonId]) itemsMap[item.comparisonId] = [];
itemsMap[item.comparisonId].push(item.name);
}
}
const data = result.map((c) => ({
id: c.id,
title: c.title,
summary: (c.summary || "").slice(0, 200),
slug: c.slug,
tags: c.tags || [],
items: itemsMap[c.id] || [],
viewCount: c.viewCount ?? 0,
status: c.status,
isPublic: c.isPublic,
createdAt: c.createdAt.toISOString(),
}));
return Response.json({ comparisons: data, total, page, limit });
}

View File

@@ -0,0 +1,23 @@
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 result = await db
.select({
totalComparisons: sql<number>`count(*)`,
totalViews: sql<number>`coalesce(sum(${comparisons.viewCount}), 0)`,
})
.from(comparisons)
.where(eq(comparisons.userId, session.user.id));
return Response.json(result[0]);
}

View File

@@ -4,7 +4,10 @@ import { db } from "./db";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }), database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
emailAndPassword: { enabled: true }, emailAndPassword: { enabled: true },
session: { expiresIn: 60 * 60 * 24 * 7 }, session: { expiresIn: 60 * 60 * 24 * 7 },
}); });

View File

@@ -20,6 +20,33 @@ export const users = pgTable("users", {
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}); });
export const accounts = pgTable("accounts", {
id: text("id").primaryKey().$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
idToken: text("id_token"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey().$defaultFn(() => createId()),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [ export const comparisonStatusEnum = pgEnum("comparison_status", [
"researching", "researching",
"completed", "completed",
@@ -33,8 +60,10 @@ export const sessions = pgTable("sessions", {
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(), ipAddress: text("ip_address"),
updatedAt: timestamp("updated_at").defaultNow().notNull(), userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}); });
export const comparisons = pgTable( export const comparisons = pgTable(

View File

@@ -178,7 +178,7 @@ Use the web research data above to provide factual, data-driven insights. Refere
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const response = await client.chat.completions.create({ const response = await getClient().chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },