diff --git a/.github/workflows/automated-tests.yml b/.github/workflows/automated-tests.yml
index 22438c06d..c67f7c3cc 100644
--- a/.github/workflows/automated-tests.yml
+++ b/.github/workflows/automated-tests.yml
@@ -15,15 +15,13 @@ jobs:
- name: Checkout Code Repository Layers
uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
-
- name: Initialize Node.js Environment
uses: actions/setup-node@v6
with:
node-version: 22
- cache: 'pnpm'
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install Project Dependencies (Clean Architecture Ingest)
run: pnpm install --frozen-lockfile
@@ -33,4 +31,3 @@ jobs:
- name: Execute Security & Code Quality Monitoring Audits
run: echo "Code coverage thresholds verified at 80%+. Monitoring data packages fully initialized."
-
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 428bb6922..b4a780194 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,13 +19,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -39,13 +38,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -62,13 +60,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -108,13 +105,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -128,13 +124,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 99da69366..b2dcc9c38 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -28,13 +28,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
- uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install app dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml
index 401300788..311b20a8f 100644
--- a/.github/workflows/visual-regression.yml
+++ b/.github/workflows/visual-regression.yml
@@ -42,15 +42,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- - uses: pnpm/action-setup@v6
- with:
- version: 11
-
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- cache: pnpm
+
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/Dockerfile b/Dockerfile
index 800716ddc..354f8ec99 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,16 @@
-FROM node:20-alpine AS base
+FROM node:22-alpine AS base
+RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
# Install dependencies only when needed
FROM base AS deps
-RUN apk add --no-cache libc6-compat
+RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
# Install dependencies based on the preferred package manager
-COPY package.json package-lock.json* pnpm-lock.yaml* ./
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN \
- if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
+ if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
@@ -18,12 +19,12 @@ RUN \
FROM base AS development
WORKDIR /app
-RUN apk add --no-cache libc6-compat
+RUN apk add --no-cache libc6-compat python3 make g++
-COPY package.json package-lock.json* pnpm-lock.yaml* ./
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN \
- if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install; \
+ if [ -f pnpm-lock.yaml ]; then pnpm install; \
elif [ -f package-lock.json ]; then npm install; \
else echo "Lockfile not found." && exit 1; \
fi
@@ -48,7 +49,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
- if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
+ if [ -f pnpm-lock.yaml ]; then pnpm run build; \
elif [ -f package-lock.json ]; then npm run build; \
else echo "Lockfile not found." && exit 1; \
fi
@@ -76,4 +77,4 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
-CMD ["node", "server.js"]
\ No newline at end of file
+CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 93184d72a..e11b4dd2a 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,189 @@ The full walkthrough — Supabase migrations, OAuth app setup, every environment
---
+## Docker Development Setup
+
+DevTrack includes Docker support for local development, allowing contributors to get started quickly without manually installing dependencies or configuring environments.
+
+### Prerequisites
+
+- Docker Desktop (Windows/macOS) or Docker Engine (Linux)
+- Docker Compose v2+
+
+Verify installation:
+
+```bash
+docker --version
+docker compose version
+```
+
+### Configure Environment Variables
+
+Copy the example environment file:
+
+```bash
+cp .env.example .env.local
+```
+
+Fill in the required values as described in the Environment Variables section above.
+
+### Start the Application
+
+Build and start the development container:
+
+```bash
+docker compose up --build
+```
+
+The application will be available at:
+
+```text
+http://localhost:3000
+```
+
+### Stop the Application
+
+```bash
+docker compose down
+```
+
+### Hot Reload Support
+
+The project source code is mounted into the container using Docker volumes.
+
+Any changes made to files on your host machine are automatically reflected inside the container, enabling Next.js hot reload during development without rebuilding the image.
+
+### Rebuild After Dependency Changes
+
+If you modify `package.json` or install new dependencies:
+
+```bash
+docker compose down
+docker compose up --build
+```
+
+### Troubleshooting
+
+Remove containers and rebuild from scratch:
+
+```bash
+docker compose down -v
+docker compose up --build
+```
+
+View container logs:
+
+```bash
+docker compose logs -f
+```
+
+Common self-hosting build issues (pnpm/Corepack mismatch, `ERR_PNPM_IGNORED_BUILDS`, missing native build tools, GitHub OAuth `error=github`) are documented in [docs/self-hosting.md](docs/self-hosting.md#docker-build-troubleshooting). Set `AUTH_DEBUG=true` in your env file for detailed NextAuth logs during OAuth setup.
+
+---
+
+## FAQ
+
+Answers to questions new contributors and self-hosters ask most often. For deeper setup detail, see [DEVELOPMENT.md](./DEVELOPMENT.md) and the [Self-Hosting Guide](./docs/self-hosting.md).
+
+
+How do I configure GitHub OAuth?
+
+1. Go to [GitHub → Settings → Developer Settings → OAuth Apps](https://github.com/settings/applications/new) and create a new OAuth App.
+2. Set the **Authorization callback URL** to:
+ - Local dev: `http://localhost:3000/api/auth/callback/github`
+ - Production: `https:///api/auth/callback/github`
+3. Copy the generated **Client ID** and **Client Secret** into `GITHUB_ID` and `GITHUB_SECRET` in `.env.local`.
+4. Make sure `NEXTAUTH_URL` matches the base URL you're running on, and `NEXTAUTH_SECRET` is set (generate one with `openssl rand -base64 32`).
+
+
+
+
+Why is login not working?
+
+This is almost always one of the following:
+
+- **Callback URL mismatch** — the URL registered on your GitHub OAuth App must exactly match `NEXTAUTH_URL` + `/api/auth/callback/github`, including protocol and trailing slashes.
+- **Missing/incorrect `NEXTAUTH_SECRET`** — sessions will silently fail without it.
+- **Stale `.env.local`** — restart `pnpm dev` after changing any auth-related env vars; Next.js doesn't hot-reload env files.
+- **Supabase RLS blocking the user row** — check that the relevant migrations from `supabase/migrations/` have been applied in order.
+
+If the issue persists, check your browser console and terminal logs for the specific NextAuth error code, then search/open a [Discussion](https://github.com/Priyanshu-byte-coder/devtrack/discussions).
+
+
+
+
+How do I obtain Supabase credentials?
+
+1. Create a free project at [supabase.com](https://supabase.com).
+2. Open **Project Settings → API**.
+3. Copy:
+ - **Project URL** → `NEXT_PUBLIC_SUPABASE_URL`
+ - **anon public key** → `NEXT_PUBLIC_SUPABASE_ANON_KEY`
+ - **service_role key** → `SUPABASE_SERVICE_ROLE_KEY` (server-side only — never expose this in client code or commit it)
+4. Run all SQL files in `supabase/migrations/` via the Supabase SQL editor, in order, before starting the app.
+
+
+
+
+Why are GitHub metrics not loading?
+
+- You're likely hitting **GitHub's unauthenticated API rate limit**. Set the optional `GITHUB_TOKEN` (a personal access token) in `.env.local` to raise the limit significantly.
+- Check that your GitHub OAuth scopes were granted during sign-in — if you denied a permission, re-authenticate by signing out and back in.
+- If you're self-hosting behind a proxy or firewall, confirm outbound requests to `api.github.com` aren't being blocked.
+- Look at the server logs/terminal for the specific API error (403 usually means rate-limited; 401 means a token problem).
+
+
+
+
+How do I run tests?
+
+```bash
+# Unit tests
+pnpm test
+
+# End-to-end tests (Playwright, first-time setup)
+npx playwright install --with-deps chromium
+pnpm run test:e2e
+
+# Run a single e2e spec
+npx playwright test e2e/goals.spec.ts
+
+# Visual regression tests
+npx playwright test -c playwright.visual.config.mjs
+```
+
+E2E tests use mocked external calls (no real GitHub/Supabase credentials needed) and also run automatically on every PR via `.github/workflows/e2e.yml`.
+
+
+
+
+How can I contribute?
+
+1. Browse [open issues](https://github.com/Priyanshu-byte-coder/devtrack/issues) and start with one labeled `good first issue`.
+2. Comment on the issue to get assigned before starting work.
+3. Fork the repo, branch off `main` (e.g. `feat/issue-42-description`), and open a PR.
+4. Before pushing, make sure CI passes locally:
+ ```bash
+ pnpm run lint && pnpm run type-check
+ ```
+5. See [CONTRIBUTING.md](./CONTRIBUTING.md) for commit message style, branch naming conventions, and the review process.
+
+Questions are welcome anytime in [Discussions](https://github.com/Priyanshu-byte-coder/devtrack/discussions).
+
+
+
+
+Which Node.js and pnpm versions are supported?
+
+| Tool | Version | Check |
+|------|---------|-------|
+| Node.js | >= 20 | `node -v` |
+| pnpm | >= 9 | `pnpm -v` |
+| Git | any | `git --version` |
+
+Install pnpm via `corepack enable` or `npm install -g pnpm` if you don't already have it.
+
+If your local versions differ and you hit install/build errors, aligning your Node.js/pnpm version with the table above is the first thing to check.
## Tech Stack
| Layer | Technology |
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 1b38cf93b..d3f98830e 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -53,6 +53,7 @@ DevTrack uses `@supabase/supabase-js` which relies on the Supabase REST API, so
| `WAKATIME_CLIENT_ID` | Optional | WakaTime OAuth Client ID (for WakaTime integration) | `waka_...` |
| `WAKATIME_CLIENT_SECRET` | Optional | WakaTime OAuth Client Secret | `waka_sec_...` |
| `ALLOWED_ORIGINS` | Optional | Comma-separated extra origins allowed for CSRF validation | `https://staging.example.com` |
+| `AUTH_DEBUG` | Optional | Set to `true` for verbose NextAuth OAuth logs (self-hosting troubleshooting) | `true` |
---
@@ -96,6 +97,8 @@ When running DevTrack with Docker, set the following environment variables in yo
```
5. DevTrack will be available at `http://localhost:3000`.
+> **Note:** The bundled `docker-compose.yml` targets the `development` stage and runs `npm run dev` with hot reload. For a production-style container, build the `production` target from the Dockerfile directly (see [Troubleshooting](#docker-build-troubleshooting)).
+
---
## 2. Railway
@@ -129,6 +132,25 @@ DevTrack includes a `render.yaml` Blueprint for easy deployment on Render's free
## 🔧 Troubleshooting
+### Docker build troubleshooting
+
+- **`pnpm` / Corepack version mismatch during `docker compose build`**:
+ The Dockerfile uses Node 22 with pnpm pinned via Corepack (`corepack prepare pnpm@11.9.0 --activate`). CI uses the same Corepack pin. Pull the latest code and rebuild with `docker compose build --no-cache`.
+
+- **`ERR_PNPM_IGNORED_BUILDS` / `pnpm approve-builds`**:
+ Build-script approval is configured non-interactively in `pnpm-workspace.yaml` (`allowBuilds`). The Dockerfile copies this file before `pnpm install`. If you see this error on an older clone, update and rebuild with `--no-cache`. Do not run interactive `pnpm approve-builds` inside CI or Docker builds.
+
+- **Native module build failures (`sharp`, `esbuild`, etc.)**:
+ The Dockerfile installs `python3`, `make`, and `g++` in the deps/development stages for Alpine. Rebuild with `docker compose build --no-cache` after pulling fixes.
+
+- **GitHub OAuth redirects with `error=github`**:
+ 1. Confirm `GITHUB_ID` and `GITHUB_SECRET` match your GitHub OAuth app.
+ 2. Set `NEXTAUTH_URL` to your public URL with no trailing slash (e.g. `https://devtrack.example.com`).
+ 3. Set the GitHub OAuth **Authorization callback URL** to `/api/auth/callback/github`.
+ 4. Enable verbose auth logging: set `AUTH_DEBUG=true` in your `.env` and inspect container logs (`docker compose logs -f`). Look for `[auth]` and `[nextauth]` lines.
+
+### General troubleshooting
+
- **Server Error 500 on Login**:
Make sure your `NEXTAUTH_SECRET` and `ENCRYPTION_KEY` are set. If `ENCRYPTION_KEY` is missing or the wrong length (must be 32 bytes / 64 hex chars), the OAuth callback will crash when attempting to encrypt the GitHub token.
- **Login Redirects back to Home Page infinitely**:
diff --git a/package.json b/package.json
index 7a4549cba..822433ea1 100644
--- a/package.json
+++ b/package.json
@@ -101,7 +101,7 @@
"js-yaml": ">=4.1.0"
},
"engines": {
- "node": "20.x"
+ "node": "20.x || 22.x"
},
"optionalDependencies": {
"@esbuild/linux-x64": "^0.28.0",
@@ -111,21 +111,5 @@
"@parcel/watcher-linux-x64-glibc": "^2.5.6",
"@parcel/watcher-linux-x64-musl": "^2.5.6",
"@swc/core-linux-x64-gnu": "^1.15.43"
- },
- "pnpm": {
- "onlyBuiltDependencies": [
- "@parcel/watcher",
- "@scarf/scarf",
- "@sentry/cli",
- "@swc/core",
- "@tree-sitter-grammars/tree-sitter-yaml",
- "core-js-pure",
- "core-js",
- "esbuild",
- "sharp",
- "tree-sitter-json",
- "tree-sitter",
- "unrs-resolver"
- ]
}
}
diff --git a/public/openapi.yaml b/public/openapi.yaml
index 01f56c03f..b8e45ea0a 100644
--- a/public/openapi.yaml
+++ b/public/openapi.yaml
@@ -865,8 +865,10 @@ paths:
type: object
'401':
description: Unauthorized
+ '429':
+ description: Rate limit exceeded
'500':
- description: AI service unavailable (GROQ_API_KEY not configured)
+ description: AI service unavailable (GEMINI_API_KEY not configured)
/api/ai/weekly-summary:
post:
@@ -890,6 +892,47 @@ paths:
'401':
description: Unauthorized
+ /api/project-tutor:
+ post:
+ tags: [AI]
+ summary: AI Project Tutor powered by Groq
+ security:
+ - cookieAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - repoUrl
+ - action
+ properties:
+ repoUrl:
+ type: string
+ maxLength: 200
+ action:
+ type: string
+ enum: [analyze, questions, chat]
+ question:
+ type: string
+ maxLength: 500
+ responses:
+ '200':
+ description: Tutor response generated successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ '400':
+ description: Invalid request parameters
+ '401':
+ description: Unauthorized
+ '429':
+ description: Rate limit exceeded
+ '500':
+ description: Internal server error / Groq API unavailable
+
# WAKATIME INTEGRATION
/api/wakatime:
get:
diff --git a/src/app/api/ai/roast/route.ts b/src/app/api/ai/roast/route.ts
index 2eb468a8c..50d4b0bcf 100644
--- a/src/app/api/ai/roast/route.ts
+++ b/src/app/api/ai/roast/route.ts
@@ -1,11 +1,52 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { resolveAppUser } from '@/lib/resolve-user';
+import {
+ upstashRateLimitFixedWindow,
+ getUpstashConfig,
+ upstashPipeline,
+} from '@/lib/upstash-rest';
+import { createMemoryFixedWindowRateLimiter } from '@/lib/rate-limit';
+import crypto from 'crypto';
+
+export const dynamic = 'force-dynamic';
// Initialize the Google Generative AI SDK
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
+const ROAST_LIMIT = 5;
+const ROAST_WINDOW_SECONDS = 60 * 60; // 1 hour
+const CACHE_TTL_SECONDS = 5 * 60; // 5 minutes
+
+// In-memory fallback rate limiter
+const memoryLimiter = createMemoryFixedWindowRateLimiter({
+ windowMs: ROAST_WINDOW_SECONDS * 1000,
+ pruneIntervalMs: ROAST_WINDOW_SECONDS * 1000,
+ maxEntries: 10_000,
+});
+
+// In-memory fallback response cache
+type CacheEntry = { value: string; expiresAt: number };
+const localCache = new Map();
+
export async function POST(req: Request) {
try {
+ // 1. Session check
+ const session = await getServerSession(authOptions);
+ if (!session?.githubId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // 2. Resolve application user
+ const user = await resolveAppUser(session.githubId, session.githubLogin);
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ const userId = user.id;
+
+ // 3. Parse and validate body
const body = await req.json();
const { mode, stats } = body;
@@ -16,15 +57,83 @@ export async function POST(req: Request) {
);
}
+ if (mode !== 'roast' && mode !== 'hype') {
+ return NextResponse.json({ error: 'Invalid mode.' }, { status: 400 });
+ }
+
+ // 4. Generate stats hash and check cache
+ const sortedLanguages = stats.languages ? [...stats.languages].sort() : [];
+ const statsString = JSON.stringify({
+ commits: stats.commits || 0,
+ languages: sortedLanguages,
+ mergedPRs: stats.mergedPRs || 0,
+ failedGoals: stats.failedGoals || 0,
+ });
+ const statsHash = crypto.createHash('sha256').update(statsString).digest('hex');
+ const cacheKey = `roast-cache:${userId}:${mode}:${statsHash}`;
+
+ // Read cache (Upstash Redis or in-memory)
+ let cachedMessage: string | null = null;
+ if (getUpstashConfig()) {
+ try {
+ const results = await upstashPipeline([['GET', cacheKey]]);
+ cachedMessage = (results[0]?.result as string) || null;
+ } catch (err) {
+ console.error('Failed to read roast from Upstash Redis:', err);
+ }
+ } else {
+ const entry = localCache.get(cacheKey);
+ if (entry && Date.now() <= entry.expiresAt) {
+ cachedMessage = entry.value;
+ } else if (entry) {
+ localCache.delete(cacheKey);
+ }
+ }
+
+ if (cachedMessage) {
+ return NextResponse.json({ message: cachedMessage, cached: true });
+ }
+
+ // 5. Rate limiting check
+ let rateLimitDenied = false;
+ let retryAfterSeconds = ROAST_WINDOW_SECONDS;
+
+ if (getUpstashConfig()) {
+ const result = await upstashRateLimitFixedWindow({
+ key: `roast-limit:${userId}`,
+ limit: ROAST_LIMIT,
+ windowSeconds: ROAST_WINDOW_SECONDS,
+ });
+ if (!result.allowed) {
+ rateLimitDenied = true;
+ retryAfterSeconds = result.retryAfter ?? ROAST_WINDOW_SECONDS;
+ }
+ } else {
+ const result = memoryLimiter.check(`roast-limit:${userId}`, ROAST_LIMIT);
+ if (!result.allowed) {
+ rateLimitDenied = true;
+ retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1);
+ }
+ }
+
+ if (rateLimitDenied) {
+ return NextResponse.json(
+ { error: 'Rate limit exceeded. Try again later.' },
+ {
+ status: 429,
+ headers: { 'Retry-After': String(retryAfterSeconds) },
+ }
+ );
+ }
+
+ // 6. Gemini Generation
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
let systemInstruction = '';
if (mode === 'roast') {
systemInstruction = `You are a hilariously brutal, sarcastic senior developer reviewing a junior's code stats. Roast their coding habits, commit streaks, or languages used based on the provided stats. Keep it strictly safe for work (SFW), funny, and under 3 sentences. No cursing.`;
- } else if (mode === 'hype') {
- systemInstruction = `You are the ultimate enthusiastic developer hype-man. Look at the user's coding stats and hype them up! Make them feel like a 10x coding god. Keep it energetic, modern, and under 3 sentences.`;
} else {
- return NextResponse.json({ error: 'Invalid mode.' }, { status: 400 });
+ systemInstruction = `You are the ultimate enthusiastic developer hype-man. Look at the user's coding stats and hype them up! Make them feel like a 10x coding god. Keep it energetic, modern, and under 3 sentences.`;
}
const prompt = `
@@ -40,9 +149,27 @@ export async function POST(req: Request) {
`;
const result = await model.generateContent(prompt);
- const responseText = result.response.text();
+ const responseText = (result.response.text() || '').trim();
+
+ if (!responseText) {
+ throw new Error('Empty response received from Gemini.');
+ }
+
+ // 7. Write to cache
+ if (getUpstashConfig()) {
+ try {
+ await upstashPipeline([['SET', cacheKey, responseText, 'EX', CACHE_TTL_SECONDS]]);
+ } catch (err) {
+ console.error('Failed to write roast to Upstash Redis:', err);
+ }
+ } else {
+ localCache.set(cacheKey, {
+ value: responseText,
+ expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000,
+ });
+ }
- return NextResponse.json({ message: responseText.trim() });
+ return NextResponse.json({ message: responseText });
} catch (error) {
console.error('Gemini API Error:', error);
return NextResponse.json(
diff --git a/src/app/api/project-tutor/route.ts b/src/app/api/project-tutor/route.ts
index a9d803b15..38e4338f0 100644
--- a/src/app/api/project-tutor/route.ts
+++ b/src/app/api/project-tutor/route.ts
@@ -1,8 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
+import { resolveAppUser } from "@/lib/resolve-user";
+import {
+ upstashRateLimitFixedWindow,
+ getUpstashConfig,
+ upstashPipeline,
+} from "@/lib/upstash-rest";
+import { createMemoryFixedWindowRateLimiter } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
+const PROJECT_TUTOR_LIMIT = 10;
+const PROJECT_TUTOR_WINDOW_SECONDS = 60 * 60; // 1 hour
+const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6 hours
+
+// In-memory fallback rate limiter
+const memoryLimiter = createMemoryFixedWindowRateLimiter({
+ windowMs: PROJECT_TUTOR_WINDOW_SECONDS * 1000,
+ pruneIntervalMs: PROJECT_TUTOR_WINDOW_SECONDS * 1000,
+ maxEntries: 10_000,
+});
+
+// In-memory fallback response cache
+type CacheEntry = { value: string; expiresAt: number };
+const localCache = new Map();
+
+async function getCachedData(key: string): Promise {
+ if (getUpstashConfig()) {
+ try {
+ const results = await upstashPipeline([["GET", key]]);
+ const val = results[0]?.result as string;
+ return val ? JSON.parse(val) : null;
+ } catch (err) {
+ console.error("Failed to read from Upstash cache:", err);
+ }
+ } else {
+ const entry = localCache.get(key);
+ if (entry && Date.now() <= entry.expiresAt) {
+ return JSON.parse(entry.value);
+ } else if (entry) {
+ localCache.delete(key);
+ }
+ }
+ return null;
+}
+
+async function setCachedData(key: string, value: any, ttlSeconds: number) {
+ const str = JSON.stringify(value);
+ if (getUpstashConfig()) {
+ try {
+ await upstashPipeline([["SET", key, str, "EX", ttlSeconds]]);
+ } catch (err) {
+ console.error("Failed to write to Upstash cache:", err);
+ }
+ } else {
+ localCache.set(key, {
+ value: str,
+ expiresAt: Date.now() + ttlSeconds * 1000,
+ });
+ }
+}
async function callGroq(prompt: string): Promise {
const apiKey = process.env.GROQ_API_KEY;
@@ -56,13 +115,21 @@ async function fetchRepoData(owner: string, repo: string) {
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
- if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.githubId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ const user = await resolveAppUser(session.githubId, session.githubLogin);
+ if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
+ const userId = user.id;
try {
const { repoUrl, action, question } = await req.json();
if (!repoUrl) return NextResponse.json({ error: "repo URL required" }, { status: 400 });
+ if (repoUrl.length > 200) {
+ return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 });
+ }
+
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 });
@@ -71,6 +138,54 @@ export async function POST(req: NextRequest) {
const githubIdentifier = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,97}[a-zA-Z0-9])?$/;
if (!githubIdentifier.test(owner) || !githubIdentifier.test(repo.replace(/\.git$/, "")))
return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 });
+
+ if (action === "chat") {
+ if (!question || typeof question !== "string" || question.length > 500) {
+ return NextResponse.json({ error: "Invalid or too long question" }, { status: 400 });
+ }
+ }
+
+ // Cache check for "analyze" and "questions" actions
+ const cacheKey = `project-tutor-cache:${owner.toLowerCase()}:${repo.toLowerCase()}:${action}`;
+ if (action === "analyze" || action === "questions") {
+ const cached = await getCachedData(cacheKey);
+ if (cached) {
+ return NextResponse.json({ ...cached, cached: true });
+ }
+ }
+
+ // Rate limit check
+ let rateLimitDenied = false;
+ let retryAfterSeconds = PROJECT_TUTOR_WINDOW_SECONDS;
+
+ if (getUpstashConfig()) {
+ const result = await upstashRateLimitFixedWindow({
+ key: `project-tutor:${userId}`,
+ limit: PROJECT_TUTOR_LIMIT,
+ windowSeconds: PROJECT_TUTOR_WINDOW_SECONDS,
+ });
+ if (!result.allowed) {
+ rateLimitDenied = true;
+ retryAfterSeconds = result.retryAfter ?? PROJECT_TUTOR_WINDOW_SECONDS;
+ }
+ } else {
+ const result = memoryLimiter.check(`project-tutor:${userId}`, PROJECT_TUTOR_LIMIT);
+ if (!result.allowed) {
+ rateLimitDenied = true;
+ retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1);
+ }
+ }
+
+ if (rateLimitDenied) {
+ return NextResponse.json(
+ { error: "Rate limit exceeded. Try again later." },
+ {
+ status: 429,
+ headers: { "Retry-After": String(retryAfterSeconds) },
+ }
+ );
+ }
+
const { repoData, readmeContent, languages } = await fetchRepoData(owner, repo);
const techStack = Object.keys(languages).join(", ") || "Unknown";
@@ -103,7 +218,9 @@ ${context}
Format your response with clear headings using markdown.`;
const analysis = await callGroq(prompt);
- return NextResponse.json({ analysis, techStack, description: repoData.description });
+ const resData = { analysis, techStack, description: repoData.description };
+ await setCachedData(cacheKey, resData, CACHE_TTL_SECONDS);
+ return NextResponse.json(resData);
}
if (action === "questions") {
@@ -129,19 +246,20 @@ Format as JSON:
Return ONLY the JSON, no other text.`;
const raw = await callGroq(prompt);
+ let questions;
try {
const clean = raw.replace(/```json|```/g, "").trim();
- const questions = JSON.parse(clean);
- return NextResponse.json({ questions });
+ questions = JSON.parse(clean);
} catch {
- return NextResponse.json({
- questions: {
- easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"],
- medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"],
- advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"],
- }
- });
+ questions = {
+ easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"],
+ medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"],
+ advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"],
+ };
}
+ const resData = { questions };
+ await setCachedData(cacheKey, resData, CACHE_TTL_SECONDS);
+ return NextResponse.json(resData);
}
if (action === "chat") {
diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts
new file mode 100644
index 000000000..6203c9066
--- /dev/null
+++ b/src/lib/auth-config.ts
@@ -0,0 +1,61 @@
+const PLACEHOLDER_PATTERNS = [
+ /^your[-_]/i,
+ /^changeme$/i,
+ /^placeholder$/i,
+ /^xxx+$/i,
+ /^todo$/i,
+];
+
+function isUnset(value: string | undefined): boolean {
+ if (!value || value.trim() === "") return true;
+ return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value.trim()));
+}
+
+function logAuthConfigStatus(): void {
+ const issues: string[] = [];
+
+ if (isUnset(process.env.GITHUB_ID)) {
+ issues.push("GITHUB_ID is unset — GitHub sign-in will fail");
+ }
+ if (isUnset(process.env.GITHUB_SECRET)) {
+ issues.push("GITHUB_SECRET is unset — GitHub sign-in will fail");
+ }
+ if (isUnset(process.env.NEXTAUTH_SECRET)) {
+ issues.push("NEXTAUTH_SECRET is unset — sessions cannot be signed");
+ }
+ if (isUnset(process.env.NEXTAUTH_URL)) {
+ issues.push(
+ "NEXTAUTH_URL is unset — OAuth callbacks may redirect incorrectly"
+ );
+ } else if (process.env.NEXTAUTH_URL?.endsWith("/")) {
+ issues.push(
+ "NEXTAUTH_URL has a trailing slash — remove it (e.g. https://example.com)"
+ );
+ }
+
+ if (issues.length > 0) {
+ console.warn(
+ "[auth] Self-hosting configuration issues:\n - " + issues.join("\n - ")
+ );
+ } else if (process.env.AUTH_DEBUG === "true") {
+ console.info("[auth] GitHub OAuth and NextAuth env vars look configured");
+ }
+}
+
+logAuthConfigStatus();
+
+export const authDebugEnabled = process.env.AUTH_DEBUG === "true";
+
+export const nextAuthLogger = {
+ error(code: string, metadata: unknown) {
+ console.error("[nextauth]", code, metadata);
+ },
+ warn(code: string) {
+ console.warn("[nextauth]", code);
+ },
+ debug(code: string, metadata: unknown) {
+ if (authDebugEnabled) {
+ console.debug("[nextauth]", code, metadata);
+ }
+ },
+};
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 66b24a92a..ce9fa5903 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,5 +1,6 @@
import { type NextAuthOptions } from "next-auth";
import GitHubProvider from "next-auth/providers/github";
+import { authDebugEnabled, nextAuthLogger } from "./auth-config";
import { syncGitHubAchievementsForUser } from "./github-achievements";
import { supabaseAdmin } from "./supabase";
@@ -14,6 +15,8 @@ const GITHUB_API = "https://api.github.com";
const TOKEN_VALIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
export const authOptions: NextAuthOptions = {
+ debug: authDebugEnabled,
+ logger: nextAuthLogger,
// Playwright runs on plain HTTP (127.0.0.1) and relies on the default
// `next-auth.session-token` cookie name. If NextAuth infers HTTPS via
// forwarded headers, it may switch to secure cookie prefixes and the E2E
@@ -47,6 +50,13 @@ export const authOptions: NextAuthOptions = {
if (account?.provider === "github" && profile) {
const p = profile as { id: number; login: string; email?: string };
+ if (authDebugEnabled) {
+ console.debug("[auth] GitHub signIn callback", {
+ login: p.login,
+ supabaseConfigured: Boolean(supabaseAdmin),
+ });
+ }
+
// Guard: supabaseAdmin is null when Supabase env vars are missing or
// contain placeholder values (see src/lib/supabase.ts). Calling .from()
// on null throws a TypeError which NextAuth silently converts to
@@ -54,8 +64,8 @@ export const authOptions: NextAuthOptions = {
// so authentication can still succeed with degraded functionality.
if (!supabaseAdmin) {
console.warn(
- "signIn: supabaseAdmin is not configured; skipping DB upsert. " +
- "Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local."
+ "[auth] supabaseAdmin is not configured; skipping DB upsert. " +
+ "Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY."
);
return true;
}