7 Commits

Author SHA1 Message Date
Christopher Mayor
50b9be2f1c fix #4: wire Profile page to real API data (user comparisons, stats, auth session) 2026-04-28 06:56:02 -07:00
Christopher Mayor
a2dabd527f feat: support OpenRouter/custom LLM providers via env vars
Add LLM_API_KEY, LLM_BASE_URL, LLM_MODEL env vars so any
OpenAI-compatible API (OpenRouter, etc.) can be used as the LLM
backend without code changes.
2026-04-28 06:56:02 -07:00
Christopher Mayor
cfe50af1af fix #12: middleware __Secure- cookie prefix check
Middleware only checked for better-auth.session_token but HTTPS uses
__Secure-better-auth.session_token, causing all protected routes to
redirect to sign-in even when authenticated.
2026-04-28 06:56:02 -07:00
Christopher Mayor
2e138a8364 fix #12: extract session token before dot (Better Auth signed cookie)
Better Auth cookie format is 'token.signature' but DB only stores the
token portion. Split on '.' to extract the actual session token.
2026-04-28 06:56:02 -07:00
Christopher Mayor
d8eb0eef8e fix #12: handle __Secure- cookie prefix in all auth bypass code
Better Auth sets cookies with __Secure- prefix when served over HTTPS.
Updated cookie parsing in compare, user/comparisons, and user/stats
routes to check for both __Secure-better-auth.session_token and
better-auth.session_token.
2026-04-28 06:56:02 -07:00
Christopher Mayor
371755c241 fix #12: remove all auth.api.getSession() calls
- middleware.ts: cookie-presence check only (Edge Runtime can't use DB),
  skip auth for API routes entirely
- compare/route.ts: manual session token parsing + db.select() queries
- user/comparisons/route.ts: same manual auth bypass
- user/stats/route.ts: same manual auth bypass

Root cause: Drizzle 0.45.2 queryWithCache bug triggers when
auth.api.getSession() is called from non-route-handler contexts.
Bypass entirely with direct db.select() on sessions/users tables.
2026-04-28 06:56:02 -07:00
Christopher Mayor
fe5153c4e5 fix #12: bypass auth.api.getSession() Drizzle queryWithCache bug
Manually parse session token from cookie and query sessions/users
tables via db.select() (regular query builder) instead of using
auth.api.getSession() which triggers Drizzle 0.45.2 queryWithCache
internal error when called from non-route-handler async context.
2026-04-28 06:56:02 -07:00
7 changed files with 280 additions and 49 deletions

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { BarChart3, Eye, Calendar, Plus, ArrowRight, RefreshCw, LogIn } from "lucide-react"
import { BarChart3, Eye, Calendar, Plus, RefreshCw, LogIn } from "lucide-react"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
@@ -17,7 +17,8 @@ interface Comparison {
items: string[]
tags: string[]
viewCount: number
overallScore: number
status: string
isPublic: boolean
createdAt: string
}
@@ -25,6 +26,7 @@ interface UserComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
interface UserStats {
@@ -33,21 +35,102 @@ interface UserStats {
}
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 { data: session, isPending: sessionLoading } = useSession()
const [comparisons, setComparisons] = useState<Comparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [stats, setStats] = useState<UserStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const limit = 20
const fetchComparisons = useCallback(async (pageNum: number) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: pageNum.toString(),
limit: limit.toString(),
})
const res = await fetch(`/api/user/comparisons?${params}`)
if (res.status === 401) {
setError("Not authenticated")
return
}
if (!res.ok) throw new Error("Failed to fetch comparisons")
const data: UserComparisonsResponse = await res.json()
setComparisons(data.comparisons)
setTotal(data.total)
setPage(pageNum)
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/user/stats")
if (res.ok) {
const data: UserStats = await res.json()
setStats(data)
}
} catch {
// Stats are non-critical, don't block the page
}
}, [])
useEffect(() => {
if (!sessionLoading && session?.user) {
fetchComparisons(1)
fetchStats()
} else if (!sessionLoading && !session?.user) {
setLoading(false)
}
}, [sessionLoading, session, fetchComparisons, fetchStats])
// Not authenticated — show sign-in prompt
if (!sessionLoading && !session?.user) {
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6">
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<LogIn className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Sign in to view your profile</p>
<p className="text-sm text-muted-foreground">
View your comparisons and stats
</p>
</div>
<Link href="/sign-in">
<Button className="gap-2">
<LogIn className="size-4" />
Sign In
</Button>
</Link>
</div>
</Card>
</div>
)
}
const user = session.user
const statsCards = [
{ label: "Comparisons", value: stats?.totalComparisons ?? 0, icon: BarChart3 },
{ label: "Total Views", value: stats?.totalViews ?? 0, icon: Eye },
]
const comparisons: Comparison[] = []
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6">
<Avatar className="size-20">
<AvatarImage src={user.avatar} />
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{user.name.split(" ").map((n) => n[0]).join("")}
{user.name?.split(" ").map((n) => n[0]).join("") ?? "?"}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
@@ -56,15 +139,18 @@ export default function ProfilePage() {
</div>
</div>
{/* Stats cards */}
<div className="grid gap-4 sm:grid-cols-2">
{stats.map((stat) => (
{statsCards.map((stat) => (
<Card key={stat.label}>
<CardContent className="flex items-center gap-4 p-4">
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<stat.icon className="size-5 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-2xl font-bold">
{loading ? <Skeleton className="h-7 w-16" /> : stat.value.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</CardContent>
@@ -72,6 +158,7 @@ export default function ProfilePage() {
))}
</div>
{/* User comparisons */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">My Comparisons</h2>
@@ -83,16 +170,50 @@ export default function ProfilePage() {
</Link>
</div>
{comparisons.length > 0 ? (
{loading ? (
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(4)].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-1/2" />
</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>
) : 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">
<BarChart3 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(1); fetchStats() }} className="gap-2">
<RefreshCw className="size-4" />
Retry
</Button>
</div>
</Card>
) : comparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{comparisons.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">
<CardHeader className="pb-3">
<CardTitle className="text-base line-clamp-1">{comparison.title}</CardTitle>
<CardDescription className="flex items-center gap-2 text-xs">
<Calendar className="size-3.5" />
{comparison.createdAt}
{new Date(comparison.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -112,9 +233,9 @@ export default function ProfilePage() {
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
</span>
{!comparison.isPublic && (
<Badge variant="outline" className="text-xs">Draft</Badge>
)}
</div>
</div>
</CardContent>
@@ -143,6 +264,19 @@ export default function ProfilePage() {
</div>
</Card>
)}
{/* Pagination */}
{!loading && comparisons.length > 0 && comparisons.length < total && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => fetchComparisons(page + 1)}
className="gap-2"
>
Load More
</Button>
</div>
)}
</div>
</div>
)

View File

@@ -2,10 +2,9 @@ import { runResearch } from "@/lib/llm";
import type { ComparisonRequest } from "@/lib/llm/types";
import type { ComparisonData } from "@/lib/types";
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { comparisons, comparisonItems, sessions, users } from "@/lib/db/schema";
import { eq, and, gt } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { auth } from "@/lib/auth";
function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -24,11 +23,41 @@ function slugify(text: string): string {
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
// Manually parse session token from cookie and query sessions table directly
const cookieHeader = request.headers.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const userId = userRows[0].id;
const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json();
const { query, items, dimensions } = body;
@@ -60,7 +89,7 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({
id,
userId: session.user.id,
userId: userId,
title,
query: query ?? title,
slug,

View File

@@ -1,22 +1,47 @@
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 { comparisons, comparisonItems, sessions, users } from "@/lib/db/schema";
import { eq, desc, sql, inArray, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
const hdrs = await headers();
const cookieHeader = hdrs.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = userRows[0].id;
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 where = eq(comparisons.userId, userId);
const [result, countResult] = await Promise.all([
db

View File

@@ -1,23 +1,48 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { comparisons, sessions, users } from "@/lib/db/schema";
import { eq, sql, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
const hdrs = await headers();
const cookieHeader = hdrs.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = userRows[0].id;
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));
.where(eq(comparisons.userId, userId));
return Response.json(result[0]);
}

View File

@@ -13,9 +13,9 @@ export interface Provider {
}
export function getActiveProvider(): Provider {
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!process.env.LLM_API_KEY;
const hasTavily = !!process.env.TAVILY_API_KEY;
const hasPerplexity = !!process.env.PERPLEXITY_API_KEY;
const hasOpenAI = !!process.env.OPENAI_API_KEY;
if (hasTavily && hasPerplexity) {
console.log("[llm] Using provider: Tavily search + Perplexity synthesis");

View File

@@ -8,10 +8,16 @@ import type {
import type { SearchResult } from "./tavily";
let _client: OpenAI | null = null;
const MODEL = process.env.LLM_MODEL || "gpt-4o-mini";
function getClient(): OpenAI {
if (!_client) {
_client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const baseURL = process.env.LLM_BASE_URL || undefined;
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY;
if (!apiKey) {
throw new Error("No API key configured. Set OPENAI_API_KEY or LLM_API_KEY.");
}
_client = new OpenAI({ apiKey, baseURL });
}
return _client;
}
@@ -112,7 +118,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
@@ -179,7 +185,7 @@ Use the web research data above to provide factual, data-driven insights. Refere
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{

View File

@@ -1,9 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"];
const protectedPaths = ["/compare", "/profile"];
function hasSessionCookie(headers: Headers): boolean {
const cookieHeader = headers.get("cookie") ?? "";
return cookieHeader
.split(";")
.some((c) => {
const trimmed = c.trim();
return trimmed.startsWith("better-auth.session_token=") || trimmed.startsWith("__Secure-better-auth.session_token=");
});
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -15,6 +24,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
// API routes handle their own auth — skip middleware session check
if (pathname.startsWith("/api/")) {
return NextResponse.next();
}
const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"),
);
@@ -27,11 +41,9 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session && isProtected) {
// Cookie-presence check only — real auth happens in route handlers.
// auth.api.getSession() bypassed due to Drizzle queryWithCache bug (#12).
if (!hasSessionCookie(request.headers) && isProtected) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);