38 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
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
39 changed files with 1816 additions and 232 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.

View File

@@ -85,6 +85,44 @@ npm run dev
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
```

View File

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

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,97 +1,98 @@
"use client"
import { useState } from "react"
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"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Search, Eye, BarChart3, Filter, X, Loader2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Search, Eye, Filter, X, Loader2, RefreshCw } from "lucide-react"
import Link from "next/link"
const allComparisons = [
{
id: "1",
title: "React vs Vue vs Svelte",
description: "Frontend framework comparison for modern web development",
items: ["React", "Vue", "Svelte"],
tags: ["Tech", "JavaScript"],
author: "Alex Johnson",
overallScore: 8.5,
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,
},
]
interface Comparison {
id: string
title: string
summary: string
slug: string
tags: string[]
items: string[]
viewCount: number
createdAt: string
}
const categories = ["All", "Tech", "Products", "AI", "Cloud", "Productivity"]
interface ComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const [loading, setLoading] = useState(false)
const searchParams = useSearchParams()
const initialSearch = searchParams.get("search") ?? ""
const filteredComparisons = allComparisons.filter((comparison) => {
const matchesSearch =
searchQuery === "" ||
comparison.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
comparison.items.some((item) =>
item.toLowerCase().includes(searchQuery.toLowerCase())
)
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(initialSearch)
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch)
const [selectedCategory, setSelectedCategory] = useState("All")
const limit = 20
const fetchComparisons = useCallback(async (pageNum: number, search: string, append = false) => {
setLoading(true)
setError(null)
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, initialSearch)
}, [fetchComparisons, initialSearch])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]
const filteredComparisons = comparisons.filter((comparison) => {
const matchesCategory =
selectedCategory === "All" ||
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 (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4">
@@ -125,14 +126,44 @@ export default function ExplorePage() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-primary" />
{loading && comparisons.length === 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[...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>
) : 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 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{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">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
@@ -141,7 +172,7 @@ export default function ExplorePage() {
</CardTitle>
</div>
<CardDescription className="text-sm line-clamp-2">
{comparison.description}
{comparison.summary}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -153,15 +184,6 @@ export default function ExplorePage() {
))}
</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">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
@@ -169,10 +191,7 @@ export default function ExplorePage() {
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<Eye className="size-3.5" />
{comparison.views.toLocaleString()}
</span>
<span className="font-semibold text-foreground bg-primary/10 px-2 py-0.5 rounded">
{comparison.overallScore}/10
{comparison.viewCount.toLocaleString()}
</span>
</div>
</div>
@@ -206,11 +225,19 @@ export default function ExplorePage() {
</Card>
)}
{loading && comparisons.length > 0 && (
<div className="flex justify-center py-4">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="outline" className="gap-2">
<Button variant="outline" onClick={loadMore} className="gap-2">
Load More
</Button>
</div>
)}
</div>
)
}

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

@@ -1,78 +1,193 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { BarChart3, Eye, Calendar, Plus, ArrowRight } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
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"
const mockUser = {
name: "Alex Johnson",
email: "alex@example.com",
avatar: "/placeholder-avatar.png",
interface Comparison {
id: string
title: string
slug: string
items: string[]
tags: string[]
viewCount: number
status: string
isPublic: boolean
createdAt: string
}
const mockComparisons = [
{
id: "1",
title: "React vs Vue vs Svelte",
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",
},
]
interface UserComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
const stats = [
{ label: "Total Comparisons", value: 12, icon: BarChart3 },
{ label: "Total Views", value: "8.2K", icon: Eye },
]
interface UserStats {
totalComparisons: number
totalViews: number
}
export default function ProfilePage() {
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 },
]
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={mockUser.avatar} />
<AvatarImage src={user.image ?? undefined} />
<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>
</Avatar>
<div className="space-y-1.5">
<h1 className="text-2xl font-bold">{mockUser.name}</h1>
<p className="text-muted-foreground">{mockUser.email}</p>
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-muted-foreground">{user.email}</p>
</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>
@@ -80,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>
@@ -91,16 +207,51 @@ export default function ProfilePage() {
</Link>
</div>
{mockComparisons.length > 0 ? (
{loading ? (
<div className="grid gap-4 sm:grid-cols-2">
{mockComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
{[...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) => (
<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" />
{comparison.createdAt}
{new Date(comparison.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -118,16 +269,59 @@ export default function ProfilePage() {
<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.views.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
{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>
</Card>
</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>
) : (
@@ -151,6 +345,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

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

View File

@@ -2,8 +2,8 @@ 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";
function serializeSSE(event: string, data: unknown): string {
@@ -23,6 +23,41 @@ function slugify(text: string): string {
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) {
// 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;
@@ -54,8 +89,9 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({
id,
userId: userId,
title,
query: query || null,
query: query ?? title,
slug,
status: "researching",
});

View File

@@ -0,0 +1,136 @@
import { db } from "@/lib/db";
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 }> }
) {
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);
}
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

@@ -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,94 @@
import { db } from "@/lib/db";
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) {
// 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, userId);
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,48 @@
import { db } from "@/lib/db";
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() {
// 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, userId));
return Response.json(result[0]);
}

View File

@@ -1,10 +1,18 @@
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 }),
database: drizzleAdapter(db, {
provider: "pg",
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

@@ -20,6 +20,33 @@ export const users = pgTable("users", {
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", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [
"researching",
"completed",
@@ -32,9 +59,11 @@ export const sessions = pgTable("sessions", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const comparisons = pgTable(

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 },
@@ -178,8 +184,8 @@ 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 client.chat.completions.create({
model: "gpt-4o-mini",
const response = await getClient().chat.completions.create({
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"]
}