Merge branch 'feat/backend'

This commit is contained in:
Christopher Mayor
2026-04-24 15:01:37 -07:00
9 changed files with 107 additions and 12 deletions

6
.env.example Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

21
Dockerfile Normal file
View File

@@ -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"]

30
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

View File

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

View File

@@ -9,6 +9,27 @@ import {
index, index,
} from "drizzle-orm/pg-core"; } 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( export const comparisons = pgTable(
"comparisons", "comparisons",
{ {

View File

@@ -7,9 +7,14 @@ import type {
} from "../types"; } from "../types";
import type { SearchResult } from "./tavily"; import type { SearchResult } from "./tavily";
const client = new OpenAI({ let _client: OpenAI | null = null;
apiKey: process.env.OPENAI_API_KEY,
}); 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. 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++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const response = await client.chat.completions.create({ const response = await getClient().chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },

View File

@@ -1,20 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; 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) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
const isPublic = publicPaths.some( const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"), (path) => pathname === path || pathname.startsWith(path + "/"),
); );
if (isPublic) { const isProtected = protectedPaths.some(
return NextResponse.next(); (path) => pathname === path || pathname.startsWith(path + "/"),
} );
if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) { if (isPublic && !isProtected) {
return NextResponse.next(); return NextResponse.next();
} }
@@ -22,7 +31,7 @@ export async function middleware(request: NextRequest) {
headers: request.headers, headers: request.headers,
}); });
if (!session) { if (!session && isProtected) {
const signInUrl = new URL("/sign-in", request.url); const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname); signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);