diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0f7812 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/comparaison +BETTER_AUTH_SECRET=change-me-to-random-string +OPENAI_API_KEY= +PERPLEXITY_API_KEY= +TAVILY_API_KEY= +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f4333aa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea7226e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.8" +services: + app: + build: . + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison + depends_on: + db: + condition: service_healthy + 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: diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dd8d37b..2599b7e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,8 +1,10 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "./db"; +import * as schema from "./db/schema"; export const auth = betterAuth({ - database: drizzleAdapter(db, { provider: "pg" }), + database: drizzleAdapter(db, { provider: "pg", schema }), emailAndPassword: { enabled: true }, + session: { expiresIn: 60 * 60 * 24 * 7 }, }); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7335881..33407e4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -9,6 +9,27 @@ import { index, } from "drizzle-orm/pg-core"; +export const users = pgTable("users", { + id: text("id").primaryKey(), + name: text("name"), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export const sessions = pgTable("sessions", { + id: text("id").primaryKey(), + userId: text("user_id") + .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(), +}); + export const comparisons = pgTable( "comparisons", { diff --git a/src/lib/llm/providers/openai.ts b/src/lib/llm/providers/openai.ts index f4eadd4..64f1bdc 100644 --- a/src/lib/llm/providers/openai.ts +++ b/src/lib/llm/providers/openai.ts @@ -7,9 +7,14 @@ import type { } from "../types"; import type { SearchResult } from "./tavily"; -const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); +let _client: OpenAI | null = null; + +function getClient(): OpenAI { + if (!_client) { + _client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + } + return _client; +} const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data. @@ -106,7 +111,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation. for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { - const response = await client.chat.completions.create({ + const response = await getClient().chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: SYSTEM_PROMPT }, diff --git a/src/middleware.ts b/src/middleware.ts index da56471..c88a27c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,20 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -const publicPaths = ["/sign-in", "/sign-up", "/api/auth"]; +const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"]; +const protectedPaths = ["/compare", "/profile"]; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + if ( + pathname.startsWith("/_next") || + pathname.startsWith("/favicon") || + pathname.includes(".") + ) { + return NextResponse.next(); + } + const isPublic = publicPaths.some( (path) => pathname === path || pathname.startsWith(path + "/"), ); - if (isPublic) { - return NextResponse.next(); - } + const isProtected = protectedPaths.some( + (path) => pathname === path || pathname.startsWith(path + "/"), + ); - if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) { + if (isPublic && !isProtected) { return NextResponse.next(); } @@ -22,7 +31,7 @@ export async function middleware(request: NextRequest) { headers: request.headers, }); - if (!session) { + if (!session && isProtected) { const signInUrl = new URL("/sign-in", request.url); signInUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(signInUrl);