21 Commits

Author SHA1 Message Date
Christopher Mayor
54879f3ab5 fix: force-dynamic on explore page to prevent static prerender crash with useSearchParams 2026-04-28 08:39:13 -07:00
Christopher Mayor
776f121eae fix: merge comparisons [id] and [slug] routes to resolve ambiguous route error 2026-04-28 08:31:01 -07:00
Christopher Mayor
1c6e36cc6f feat: implement issues #5-#8 — error states, header search, delete/toggle visibility, auth-aware UI 2026-04-28 08:21:00 -07:00
Christopher Mayor
e1d97178a1 fix: force-dynamic on profile page to prevent static prerender crash 2026-04-28 08:13:50 -07:00
Christopher Mayor
3f3932082c fix: update package-lock.json for playwright dependency 2026-04-28 07:19:41 -07:00
Christopher Mayor
e0cbba6dc5 fix: type safety for profile page, exclude e2e from tsconfig 2026-04-28 07:15:59 -07:00
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
Hermes Agent
26c7ad4d7b Add Playwright E2E test suite (25 tests across 4 specs)
- playwright.config.ts: headless CI setup with JSON/HTML reporters
- e2e/global-setup.ts: app reachability check before tests
- e2e/auth.spec.ts: 6 auth tests (sign-in, session, protected routes)
- e2e/compare.spec.ts: 8 API tests (validation, SSE flow, DB persistence)
- e2e/comparisons.spec.ts: 6 CRUD tests (list, detail, 404, view counts)
- e2e/user.spec.ts: 5 user tests (stats, pagination, session binding)
- e2e/helpers.ts: shared SSE parsing, auth, URL resolution utilities
- e2e/README.md: setup and run instructions
- AGENTS.md: updated with LLM provider notes and testing docs
- .gitignore: added playwright report/result directories
- package.json: added @playwright/test and test:e2e scripts
2026-04-27 20:48:05 -07:00
Christopher Mayor
8f64ccd2f6 fix: pass original request headers to auth.api.getSession 2026-04-27 12:05:50 -07:00
Christopher Mayor
419d96aedc fix: disable drizzle query cache (NoopCache) to fix queryWithCache bug 2026-04-27 12:01:42 -07:00
Christopher Mayor
561e7b546e debug: add postgres.js debug logging 2026-04-27 11:57:23 -07:00
Christopher Mayor
d686d1bd4f fix: use plain Headers for auth session lookup in compare route 2026-04-27 11:43:42 -07:00
Christopher Mayor
3cb771a1cd debug: add error catching to compare getSession 2026-04-27 11:39:06 -07:00
Christopher Mayor
b44277506a fix: add withTimezone to session and verification expiresAt timestamps 2026-04-27 11:35:02 -07:00
Christopher Mayor
33d68502fb fix #12: explicit model name mapping in drizzle adapter schema 2026-04-27 11:29:12 -07:00
31 changed files with 1364 additions and 95 deletions

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@
# testing
/coverage
/playwright-report/
/playwright-results.json
/test-results/
/playwright/.cache/
# next.js
/.next/

View File

@@ -3,3 +3,59 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
# Comparaison — Project Agent Rules
## LLM Provider
- **Base URL**: `https://nano-gpt.com/api/v1` (not `api.nano-gpt.com`)
- **API Key**: Use `NANOGPT_API_KEY` env var from the host's Docker `.env`
- **Model**: `minimax/minimax-m2.7` (supports `response_format: {type: json_object}`)
- **Schema tolerance**: The LLM output may deviate from the requested schema. The provider code (`src/lib/llm/providers/openai.ts`) includes `normalizeResult()` to handle common deviations (overallScore as object, sources as strings, dimensions as array of objects). Do NOT assume the LLM returns the exact schema.
## Development
```bash
# Local dev
npm run dev
# Build Docker image
docker compose build
# Run container
docker compose up -d
# Run E2E tests
npm install
npm run test:e2e:install
npm run test:e2e
```
## Deployment
Deployed to `ubuntu:/srv/compose/comparaison/` via `docker compose`.
Traefik routes `comparaison.local.tophermayor.com` → container port 3000.
## Environment Variables
Required in `.env`:
- `DATABASE_URL` — PostgreSQL connection string
- `BETTER_AUTH_SECRET` — Session secret (min 32 chars)
- `BETTER_AUTH_URL` — Auth callback URL
- `LLM_API_KEY` — NanoGPT API key (Hermes `NANOGPT_API_KEY` works)
- `LLM_BASE_URL``https://nano-gpt.com/api/v1`
- `LLM_MODEL``minimax/minimax-m2.7`
## Key Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/api/compare` | POST | Yes | Start SSE comparison stream |
| `/api/comparisons/[slug]` | GET | Yes | Get comparison by slug |
| `/api/user/comparisons` | GET | Yes | List user's comparisons |
| `/api/user/stats` | GET | Yes | User statistics |
| `/api/auth/sign-in/email` | POST | No | Email/password sign-in |
## Testing
E2E tests live in `e2e/` and use Playwright. See `e2e/README.md` for setup.

76
e2e/README.md Normal file
View File

@@ -0,0 +1,76 @@
# E2E Tests
Playwright-based end-to-end tests covering auth, the compare API, comparisons CRUD, and user stats.
## Setup
```bash
# Install dependencies (includes Playwright)
npm install
# Install Chromium browser for Playwright
npm run test:e2e:install
# Copy and fill in test credentials
cp .env.test.example .env.test.local
# Edit .env.test.local with your test user credentials
```
## Running Tests
```bash
# Run all tests (headless)
npm run test:e2e
# Run with headed browser (visible)
npm run test:e2e:headed
# Run with Playwright UI (step-through debugger)
npm run test:e2e:ui
# View the HTML report from last run
npm run test:e2e:report
```
## Prerequisites
The Comparaison Docker container must be running on the target host:
```bash
ssh bear@192.168.50.61 'cd /srv/compose/comparaison && docker compose up -d'
```
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `E2E_HOST` | `192.168.50.61` | Host running the Docker container |
| `E2E_BASE_URL` | `http://localhost:3000` | App URL for browser navigation |
| `E2E_TARGET_HOST` | `192.168.50.61` | Host for API calls via Traefik |
| `E2E_TEST_EMAIL` | `admin@admin.com` | Test user email |
| `E2E_TEST_PASSWORD` | `adminpass` | Test user password |
## Test Structure
```
e2e/
global-setup.ts # Verifies app is reachable before tests
global-teardown.ts # Cleanup after tests
helpers.ts # Shared utilities (auth, SSE parsing, URL resolution)
auth.spec.ts # Authentication flows (6 tests)
compare.spec.ts # Compare API including SSE and DB persistence (8 tests)
comparisons.spec.ts # List and detail views (6 tests)
user.spec.ts # User stats and account (5 tests)
```
## CI Integration
In CI (GitHub Actions, Gitea Actions), set environment variables and run:
```bash
npm install
npm run test:e2e:install
npm run test:e2e
```
The JSON results file (`playwright-results.json`) can be uploaded as a CI artifact.

72
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
import { test, expect, type Page } from "@playwright/test";
import { resolveUrl, getSessionCookie, testUser } from "./helpers";
/**
* E2E tests for authentication flows.
* Covers: sign-in, sign-out, protected routes, and session persistence.
*/
test.describe("Auth", () => {
// ─── Helpers ───────────────────────────────────────────────────────────────
async function signInViaUI(page: Page, email = testUser.email, password = testUser.password) {
await page.goto(resolveUrl("/"));
await page.getByRole("link", { name: /sign in/i }).click();
await page.getByPlaceholder("you@example.com").fill(email);
await page.getByPlaceholder("your password").fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("**/");
}
// ─── Tests ────────────────────────────────────────────────────────────────
test("1. Homepage loads without auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/"));
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(400);
});
test("2. Sign in via API returns session cookie", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: testUser.email, password: testUser.password },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(200);
const setCookie = res.headers()["set-cookie"] ?? "";
expect(setCookie).toContain("session_token=");
});
test("3. Sign in with wrong credentials returns 401", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: "bad@example.com", password: "wrongpass" },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
});
test("4. Protected API route requires auth", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["A", "B"] },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error).toMatch(/auth/i);
});
test("5. Authenticated session persists across requests", async ({ page }) => {
const cookie = await getSessionCookie(page);
// Should be able to hit protected endpoints with the cookie
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
});
test("6. UI sign-in flow works", async ({ page }) => {
await signInViaUI(page);
// After sign-in, user should see their name or a dashboard link
const bodyText = await page.content();
expect(bodyText.toLowerCase()).not.toContain("sign in");
});
});

161
e2e/compare.spec.ts Normal file
View File

@@ -0,0 +1,161 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie, parseSSEStream, lastSSEOfType } from "./helpers";
/**
* E2E tests for the /api/compare endpoint.
* Covers: request validation, full SSE flow, success/failure states, and DB persistence.
*/
test.describe("Compare API", () => {
let cookie: string;
test.beforeEach(async ({ page }) => {
cookie = await getSessionCookie(page);
});
// ─── Input Validation ────────────────────────────────────────────────────
test("1. Rejects fewer than 2 items", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React"] },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/2 items|at least/i);
});
test("2. Rejects more than 10 items", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: Array.from({ length: 11 }, (_, i) => `Item${i}`) },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/10|maximum/i);
});
test("3. Rejects empty item names", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React", " ", "Vue"] },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/empty/i);
});
test("4. Rejects unauthenticated requests", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React", "Vue"] },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
});
// ─── Happy Path ──────────────────────────────────────────────────────────
test("5. Compare returns SSE stream with progress events", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Python vs JavaScript", items: ["Python", "JavaScript"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain("text/event-stream");
const body = await res.body();
if (!body) { test.skip(); return; }
const bodyStr = Buffer.from(body).toString("utf-8");
const events = await parseSSEStream(bodyStr);
// Should have at least one progress event
const progressEvents = events.filter((e) => e.status !== undefined);
expect(progressEvents.length).toBeGreaterThan(0);
});
test("6. Full compare flow completes successfully", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "React vs Vue", items: ["React", "Vue"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
// Must have a final status
const finalProgress = lastSSEOfType(events, "progress");
expect(finalProgress).toBeDefined();
expect(["completed", "failed"]).toContain(finalProgress!.status);
if (finalProgress!.status === "completed") {
const doneEvent = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
expect(doneEvent).toBeDefined();
const doneData = doneEvent!.done as Record<string, unknown>;
expect(doneData).toHaveProperty("id");
expect(doneData).toHaveProperty("slug");
expect((doneData.data as Record<string, unknown>)).toHaveProperty("summary");
expect((doneData.data as Record<string, unknown>)).toHaveProperty("items");
}
});
test("7. Comparison is persisted to DB after completion", async ({ page }) => {
// Run a comparison
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Rust vs Go", items: ["Rust", "Go"] },
headers: { "Content-Type": "application/json", Cookie: cookie, Accept: "text/event-stream" },
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const doneEvent = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
if (!doneEvent) { test.skip(); return; }
const { slug } = doneEvent.done as { slug: string };
expect(slug).toBeTruthy();
// Fetch it back
const getRes = await page.request.get(resolveUrl(`/api/comparisons/${slug}`), {
headers: { Cookie: cookie },
});
expect(getRes.status()).toBe(200);
const comparison = await getRes.json();
expect(comparison.slug).toBe(slug);
expect(comparison.status).toBe("completed");
});
test("8. Compare with custom dimensions", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: {
query: "React vs Angular",
items: ["React", "Angular"],
dimensions: ["Performance", "Learning Curve", "Ecosystem"],
},
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const finalProgress = lastSSEOfType(events, "progress");
expect(finalProgress).toBeDefined();
expect(["completed", "failed"]).toContain(finalProgress!.status);
});
});

95
e2e/comparisons.spec.ts Normal file
View File

@@ -0,0 +1,95 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie, parseSSEStream, lastSSEOfType } from "./helpers";
/**
* E2E tests for comparison list and detail views.
* Covers: /api/comparisons/[slug], /api/user/comparisons
*/
test.describe("Comparisons", () => {
let cookie: string;
let testSlug: string;
test.beforeAll(async ({ page }) => {
cookie = await getSessionCookie(page);
// Create a comparison to ensure we have data
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Cats vs Dogs", items: ["Cats", "Dogs"] },
headers: { "Content-Type": "application/json", Cookie: cookie, Accept: "text/event-stream" },
timeout: 120_000,
});
if (res.ok()) {
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const done = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
if (done) {
testSlug = (done.done as Record<string, unknown>).slug as string;
}
}
});
test("1. User comparisons list returns 200 for authenticated user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
test("2. User comparisons list requires auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons"));
expect(res.status()).toBe(401);
});
test("3. Get comparison by slug returns 200 with data", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("slug", testSlug);
expect(data).toHaveProperty("status");
expect(["completed", "failed", "researching"]).toContain(data.status);
});
test("4. Get comparison by non-existent slug returns 404", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/comparisons/this-slug-does-not-exist-xyz123"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(404);
});
test("5. Comparison slug increments view count", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res1 = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const viewsBefore = (await res1.json()).viewCount ?? 0;
const res2 = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const viewsAfter = (await res2.json()).viewCount ?? 0;
expect(viewsAfter).toBeGreaterThanOrEqual(viewsBefore);
});
test("6. Comparison response includes all required fields", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const data = await res.json();
expect(data).toHaveProperty("id");
expect(data).toHaveProperty("title");
expect(data).toHaveProperty("slug");
expect(data).toHaveProperty("status");
expect(data).toHaveProperty("query");
});
});

32
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { FullConfig } from "@playwright/test";
import { baseURL } from "./playwright.config";
/**
* Global setup — runs once before all tests.
* Verifies the app is reachable before running E2E suite.
*/
export default async function globalSetup(_config: FullConfig) {
const url = baseURL.replace("localhost", process.env.E2E_HOST || "192.168.50.61");
console.log(`[E2E Setup] Checking app at: ${url}`);
let attempts = 0;
while (attempts < 10) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.ok || res.status === 401) {
// 401 means the server is up (auth is working), 200 means homepage
console.log(`[E2E Setup] App reachable (HTTP ${res.status})`);
return;
}
} catch {
// ignore and retry
}
attempts++;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(
`[E2E Setup] App not reachable at ${url} after ${attempts} attempts. ` +
"Ensure the Docker container is running: `docker compose up -d`"
);
}

9
e2e/global-teardown.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { FullConfig } from "@playwright/test";
/**
* Global teardown — runs once after all tests finish.
* No-op placeholder for cleanup hooks.
*/
export default async function globalTeardown(_config: FullConfig) {
// Add cleanup logic here if needed (e.g., stop test containers)
}

53
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,53 @@
import { type Page, type Request, type Response, expect } from "@playwright/test";
import { baseURL, targetHost, testUser } from "../playwright.config";
/** Resolve URL relative to the target host (bypasses localhost resolution). */
export function resolveUrl(path: string): string {
const base = baseURL.replace("localhost", targetHost);
return `${base}${path.startsWith("/") ? path : "/" + path}`;
}
/** Sign in as the test user via the API and return the session cookie. */
export async function getSessionCookie(page: Page): Promise<string> {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: testUser.email, password: testUser.password },
headers: { "Content-Type": "application/json" },
});
if (!res.ok()) {
throw new Error(`Sign-in failed: ${res.status()} ${await res.text()}`);
}
const setCookie = res.headers()["set-cookie"] ?? "";
const match = setCookie.match(/session_token=([^;]+)/);
if (!match) throw new Error("No session_token cookie in sign-in response");
return `session_token=${match[1]}`;
}
/** Parse SSE stream from a fetch response body. */
export async function parseSSEStream(body: string): Promise<Record<string, unknown>[]> {
const events: Record<string, unknown>[] = [];
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("event:")) continue;
if (trimmed.startsWith("data:")) {
const dataStr = trimmed.slice(5).trim();
try {
events.push(JSON.parse(dataStr));
} catch {
// skip malformed
}
}
}
return events;
}
/** Extract the last SSE event of a given type. */
export function lastSSEOfType(
events: Record<string, unknown>[],
type: "progress" | "done"
): Record<string, unknown> | undefined {
return events.reverse().find((e) => e[type] !== undefined);
}
// Re-export common helpers
export { expect, resolveUrl as url, testUser };
export type { Page, Request, Response };

77
e2e/user.spec.ts Normal file
View File

@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie } from "./helpers";
/**
* E2E tests for user endpoints: stats, profile, and account management.
*/
test.describe("User API", () => {
let cookie: string;
test.beforeEach(async ({ page }) => {
cookie = await getSessionCookie(page);
});
test("1. User stats returns 200 for authenticated user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("totalComparisons");
expect(data).toHaveProperty("totalViews");
expect(typeof data.totalComparisons).toBe("number");
expect(typeof data.totalViews).toBe("number");
});
test("2. User stats requires auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"));
expect(res.status()).toBe(401);
});
test("3. User stats increments after new comparison", async ({ page }) => {
const beforeRes = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const before = await beforeRes.json();
const countBefore = before.totalComparisons ?? 0;
// Create a new comparison
const createRes = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["TypeScript", "JavaScript"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
if (createRes.status() === 200) {
// Wait briefly for DB write
await new Promise((r) => setTimeout(r, 2000));
const afterRes = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const after = await afterRes.json();
expect(after.totalComparisons).toBeGreaterThanOrEqual(countBefore);
}
});
test("4. User comparisons list pagination works", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons?page=1&limit=5"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
});
test("5. Auth session is bound to correct user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const data = await res.json();
// Stats should be non-negative integers
expect(data.totalComparisons).toBeGreaterThanOrEqual(0);
expect(data.totalViews).toBeGreaterThanOrEqual(0);
});
});

64
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@@ -2994,6 +2995,22 @@
"cuid2": "bin/cuid2.js"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -9446,6 +9463,53 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@@ -6,7 +6,12 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report",
"test:e2e:install": "playwright install chromium"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
@@ -27,6 +32,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",

41
playwright.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.E2E_BASE_URL || "http://localhost:3000";
const targetHost = process.env.E2E_TARGET_HOST || "192.168.50.61";
const testUser = {
email: process.env.E2E_TEST_EMAIL || "admin@admin.com",
password: process.env.E2E_TEST_PASSWORD || "adminpass",
};
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
["json", { outputFile: "playwright-results.json" }],
],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
globalSetup: "./e2e/global-setup.ts",
globalTeardown: "./e2e/global-teardown.ts",
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
timeout: 120_000,
expect: {
timeout: 10_000,
},
});
export { baseURL, targetHost, testUser };

View File

@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Share2, ArrowLeft, Loader2, Trophy } from "lucide-react"
import { Share2, ArrowLeft, Loader2, Trophy, AlertCircle } from "lucide-react"
import Link from "next/link"
interface ComparisonResultsClientProps {
@@ -32,6 +32,33 @@ export function ComparisonResultsClient({ initialData }: ComparisonResultsClient
const winner = [...data.items].sort((a, b) => b.overallScore - a.overallScore)[0]
if (data.status === "failed") {
return (
<div className="max-w-2xl mx-auto p-4 space-y-6">
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/30 p-6 space-y-4">
<div className="flex items-start gap-3">
<AlertCircle className="size-6 text-red-600 shrink-0 mt-0.5" />
<div className="space-y-2">
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200">
Comparison Failed
</h2>
<p className="text-sm text-red-700 dark:text-red-300">
This comparison could not be completed. This may be due to a processing error or
invalid input.
</p>
</div>
</div>
<Link href="/compare">
<Button variant="outline" className="gap-2">
<ArrowLeft className="size-4" />
Try Again
</Button>
</Link>
</div>
</div>
)
}
if (isResearching) {
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">

View File

@@ -1,23 +1,36 @@
"use client"
import { useState, useCallback, KeyboardEvent } from "react"
import { useState, useCallback, useEffect, KeyboardEvent } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useComparisonStream } from "@/hooks/use-comparison-stream"
import { X, Plus, Loader2, Sparkles } from "lucide-react"
import { X, Plus, Loader2, Sparkles, AlertCircle } from "lucide-react"
import Link from "next/link"
export default function ComparePage() {
const [query, setQuery] = useState("")
const [items, setItems] = useState<string[]>([])
const [itemInput, setItemInput] = useState("")
const [dimensionHints, setDimensionHints] = useState("")
const { progress, startResearch, cancel } = useComparisonStream()
const { progress, result, error, comparisonSlug, startResearch, cancel } = useComparisonStream()
const router = useRouter()
const isResearching = progress.status === "researching"
// Auto-navigate to comparison results when stream completes
useEffect(() => {
if (result && comparisonSlug && !isResearching) {
const timer = setTimeout(() => {
router.push(`/compare/${comparisonSlug}`)
}, 800)
return () => clearTimeout(timer)
}
}, [result, comparisonSlug, isResearching, router])
const addItem = useCallback(() => {
const trimmed = itemInput.trim()
if (trimmed && !items.includes(trimmed) && items.length < 10) {
@@ -132,6 +145,28 @@ export default function ComparePage() {
/>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/30 p-4 space-y-2">
<div className="flex items-start gap-2">
<AlertCircle className="size-5 text-red-600 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Something went wrong
</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
{(error.includes("Authentication") || error.includes("401")) && (
<Link
href="/sign-in"
className="inline-flex items-center text-sm font-medium text-red-700 underline underline-offset-2 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100"
>
Sign in to continue
</Link>
)}
</div>
</div>
</div>
)}
{isResearching && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function ExploreLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -28,13 +29,16 @@ interface ComparisonsResponse {
}
export default function ExplorePage() {
const searchParams = useSearchParams()
const initialSearch = searchParams.get("search") ?? ""
const [comparisons, setComparisons] = useState<Comparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [searchQuery, setSearchQuery] = useState(initialSearch)
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch)
const [selectedCategory, setSelectedCategory] = useState("All")
const limit = 20
@@ -71,8 +75,8 @@ export default function ExplorePage() {
}, [searchQuery, fetchComparisons])
useEffect(() => {
fetchComparisons(1, "")
}, [fetchComparisons])
fetchComparisons(1, initialSearch)
}, [fetchComparisons, initialSearch])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]

View File

@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { Sparkles, Home, BarChart3, Compass, User, Menu, X, Search } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
@@ -72,6 +72,13 @@ export default function MainLayout({
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const router = useRouter()
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && searchQuery.trim()) {
router.push(`/explore?search=${encodeURIComponent(searchQuery.trim())}`)
}
}
return (
<div className="min-h-screen flex flex-col">
@@ -90,6 +97,7 @@ export default function MainLayout({
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-9 h-9"
/>
</div>
@@ -146,6 +154,7 @@ export default function MainLayout({
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-9"
/>
</div>

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -6,7 +6,13 @@ 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 {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import { BarChart3, Eye, Calendar, Plus, RefreshCw, LogIn, MoreVertical, Trash2, Globe, Lock } from "lucide-react"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
@@ -17,7 +23,8 @@ interface Comparison {
items: string[]
tags: string[]
viewCount: number
overallScore: number
status: string
isPublic: boolean
createdAt: string
}
@@ -25,6 +32,7 @@ interface UserComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
interface UserStats {
@@ -33,21 +41,133 @@ 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])
const handleToggleVisibility = async (comparison: Comparison) => {
try {
const res = await fetch(`/api/comparisons/${comparison.slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPublic: !comparison.isPublic }),
})
if (res.ok) {
fetchComparisons(page)
fetchStats()
}
} catch {
// Silently fail — user can retry
}
}
const handleDelete = async (comparison: Comparison) => {
if (!window.confirm(`Delete "${comparison.title}"? This cannot be undone.`)) return
try {
const res = await fetch(`/api/comparisons/${comparison.slug}`, {
method: "DELETE",
})
if (res.ok) {
fetchComparisons(page)
fetchStats()
}
} catch {
// Silently fail — user can retry
}
}
// 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 +176,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 +195,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,43 +207,121 @@ 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}`}>
<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}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
</span>
<Card key={comparison.id} className="h-full group transition-all hover:border-primary hover:shadow-md">
<div className="flex flex-col h-full">
<Link href={`/compare/${comparison.slug}`} className="flex-1">
<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" />
{new Date(comparison.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</Link>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
{!comparison.isPublic ? (
<Badge variant="outline" className="text-xs">Private</Badge>
) : (
<Badge variant="outline" className="text-xs">Public</Badge>
)}
</div>
</div>
</CardContent>
</Link>
<div className="flex justify-end px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center justify-center size-8 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-accent"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<MoreVertical className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleToggleVisibility(comparison)
}}
>
{comparison.isPublic ? (
<>
<Lock className="size-4" />
Make Private
</>
) : (
<>
<Globe className="size-4" />
Make Public
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation()
handleDelete(comparison)
}}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
))}
</div>
) : (
@@ -143,7 +345,20 @@ 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,8 +1,45 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { comparisons, sessions, users } from "@/lib/db/schema";
import { eq, sql, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
import { getComparison } from "@/app/actions/comparison";
async function getAuthedUserId(): Promise<string | null> {
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 null;
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) return null;
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) return null;
return userRows[0].id;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
@@ -22,3 +59,78 @@ export async function GET(
return Response.json(comparison);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const userId = await getAuthedUserId();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const existing = await db
.select({ id: comparisons.id, userId: comparisons.userId })
.from(comparisons)
.where(eq(comparisons.slug, slug))
.limit(1);
if (!existing.length) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (existing[0].userId !== userId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
await db.delete(comparisons).where(eq(comparisons.id, existing[0].id));
return Response.json({ success: true });
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const userId = await getAuthedUserId();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const existing = await db
.select({
id: comparisons.id,
userId: comparisons.userId,
isPublic: comparisons.isPublic,
})
.from(comparisons)
.where(eq(comparisons.slug, slug))
.limit(1);
if (!existing.length) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (existing[0].userId !== userId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const newIsPublic =
typeof body.isPublic === "boolean" ? body.isPublic : !existing[0].isPublic;
const [updated] = await db
.update(comparisons)
.set({ isPublic: newIsPublic, updatedAt: new Date() })
.where(eq(comparisons.id, existing[0].id))
.returning();
return Response.json({
id: updated.id,
isPublic: updated.isPublic,
});
}

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

@@ -1,12 +1,17 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
import * as schema from "./db/schema";
import { users, sessions, accounts, verifications } from "./db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
schema: {
user: users,
session: sessions,
account: accounts,
verification: verifications,
},
}),
emailAndPassword: { enabled: true },
session: { expiresIn: 60 * 60 * 24 * 7 },

View File

@@ -1,8 +1,9 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { NoopCache } from "drizzle-orm/cache/core/cache";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export const db = drizzle(client, { schema, cache: new NoopCache() });

View File

@@ -42,7 +42,7 @@ export const verifications = pgTable("verifications", {
id: text("id").primaryKey().$defaultFn(() => createId()),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
@@ -59,7 +59,7 @@ export const sessions = pgTable("sessions", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),

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);

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
}