Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 96 additions & 96 deletions docs/architecture.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
"closeMenu": "Close navigation menu",
"loggedInAs": "Logged in as",
"settings": "Settings",
"signInCta": "SIGN IN",
"signOutCta": "Sign out"
"signInCta": "Sign in",
"signOutCta": "Sign out",
"openDashboard": "Open dashboard"
},
"dashboard": {
"overviewEyebrow": "Dashboard overview",
Expand Down Expand Up @@ -117,4 +118,4 @@
"empty": "No public GitHub achievements available yet.",
"badgeAlt": "{title} GitHub achievement badge"
}
}
}
7 changes: 4 additions & 3 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
"closeMenu": "Cerrar menú de navegación",
"loggedInAs": "Sesión iniciada como",
"settings": "Configuración",
"signInCta": "INICIAR SESIÓN",
"signOutCta": "Cerrar sesión"
"signInCta": "Iniciar sesión",
"signOutCta": "Cerrar sesión",
"openDashboard": "Abrir panel"
},
"dashboard": {
"overviewEyebrow": "Resumen del panel",
Expand Down Expand Up @@ -117,4 +118,4 @@
"empty": "Aún no hay logros públicos de GitHub disponibles.",
"badgeAlt": "Insignia del logro de GitHub {title}"
}
}
}
12 changes: 12 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ const nextConfig = {
},
],
},
// Signed-in tools moved under /dashboard so they share the app shell.
// Query strings are carried over automatically.
async redirects() {
return [
{ source: "/settings", destination: "/dashboard/settings", permanent: true },
{ source: "/friend-compare", destination: "/dashboard/friends", permanent: true },
{ source: "/rooms", destination: "/dashboard/rooms", permanent: true },
{ source: "/rooms/:roomId", destination: "/dashboard/rooms/:roomId", permanent: true },
{ source: "/project-tutor", destination: "/dashboard/project-tutor", permanent: true },
{ source: "/profile-card", destination: "/dashboard/settings", permanent: true },
];
},
async headers() {
return [
{
Expand Down
11 changes: 11 additions & 0 deletions public/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,17 @@ paths:
security:
- cronAuth: []
/api/cv/analyze:
get:
operationId: get_api_cv_analyze
summary: GET /api/cv/analyze
tags:
- cv
responses:
'200':
description: Successful response. See the route contract for the returned JSON fields.
x-contract-detail: Route and method are verified from source; endpoint-specific JSON schemas have not yet been expanded.
security:
- cookieAuth: []
post:
operationId: post_api_cv_analyze
summary: POST /api/cv/analyze
Expand Down
113 changes: 75 additions & 38 deletions src/app/api/cv/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/server-session";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import type { ContributionClassification, CVAnalyzeResponse } from "@/types/cv-types";
import type {
ContributionClassification,
CVAnalyzeResponse,
} from "@/types/cv-types";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -46,6 +49,44 @@ async function resolveGitHubLogin(
return data.login;
}

async function loadCachedAnalysis(
userId: string
): Promise<ContributionClassification | null> {
const { data } = await supabaseAdmin
.from("cv_analyses")
.select("analysis_data")
.eq("user_id", userId)
.gte("expires_at", new Date().toISOString())
.order("generated_at", { ascending: false })
.limit(1)
.maybeSingle();
return (data?.analysis_data as unknown as ContributionClassification) ?? null;
}

/**
* GET /api/cv/analyze
*
* Returns the cached analysis (24 h TTL) without touching GitHub, so the
* Resume page can resume where the user left off. 404 when none exists.
*/
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const analysis = await loadCachedAnalysis(session.githubId);
if (!analysis) {
return NextResponse.json({ error: "No analysis yet" }, { status: 404 });
}
const response: CVAnalyzeResponse = { analysis, cached: true };
return NextResponse.json(response);
} catch (err) {
console.error("CV analyze cache read error:", err);
return NextResponse.json({ error: "No analysis yet" }, { status: 404 });
}
}

/**
* POST /api/cv/analyze
*
Expand All @@ -63,7 +104,14 @@ export async function POST() {

const userId = session.githubId;

/* ── 2. Rate limiting ────────────────────────────────────── */
/* ── 2. Cache check ──────────────────────────────────────── */
const cached = await loadCachedAnalysis(userId);
if (cached) {
const response: CVAnalyzeResponse = { analysis: cached, cached: true };
return NextResponse.json(response);
}

/* ── 3. Rate limiting (only fresh analyses count) ────────── */
const currentTime = Date.now();

let existing = analyzeRateLimit.get(userId);
Expand All @@ -74,7 +122,9 @@ export async function POST() {

if (existing.count >= MAX_REQUESTS) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{
error: `GitHub analysis is limited to ${MAX_REQUESTS} runs per hour. Try again in ${Math.ceil((existing.resetTime - currentTime) / 60000)} min.`,
},
{
status: 429,
headers: {
Expand All @@ -87,54 +137,38 @@ export async function POST() {
}
existing.count += 1;

/* ── 3. Cache check ──────────────────────────────────────── */
const { data: cached } = await supabaseAdmin
.from("cv_analyses")
.select("*")
.eq("user_id", userId)
.gte("expires_at", new Date().toISOString())
.order("generated_at", { ascending: false })
.limit(1)
.maybeSingle();

if (cached) {
const response: CVAnalyzeResponse = {
analysis: cached.analysis_data as unknown as ContributionClassification,
cached: true,
};
return NextResponse.json(response);
}

/* ── 4. Resolve login & fetch contributions ───────────────── */
const accessToken = session.accessToken as string;
const githubLogin = await resolveGitHubLogin(
session.githubLogin as string | undefined,
accessToken
);

const { fetchContributionData } = await import(
"@/lib/cv/cv-github-fetcher"
);
const { classifyContributions } = await import(
"@/lib/cv/cv-classifier"
);
const { fetchContributionData } =
await import("@/lib/cv/cv-github-fetcher");
const { classifyContributions } = await import("@/lib/cv/cv-classifier");

const contributionData = await fetchContributionData(accessToken, githubLogin);
const contributionData = await fetchContributionData(
accessToken,
githubLogin
);
const analysis = classifyContributions(contributionData);

/* ── 5. Cache in Supabase (24 h TTL) — non-fatal ─────────── */
const now = new Date();
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000);

const { error: upsertError } = await supabaseAdmin.from("cv_analyses").upsert(
{
user_id: userId,
analysis_data: toDatabaseJson(analysis),
generated_at: now.toISOString(),
expires_at: expiresAt.toISOString(),
},
{ onConflict: "user_id" }
);
const { error: upsertError } = await supabaseAdmin
.from("cv_analyses")
.upsert(
{
user_id: userId,
analysis_data: toDatabaseJson(analysis),
generated_at: now.toISOString(),
expires_at: expiresAt.toISOString(),
},
{ onConflict: "user_id" }
);

if (upsertError) {
// Log but don't fail — analysis is computed, just not cached.
Expand All @@ -153,7 +187,10 @@ export async function POST() {

console.error("CV analyze error:", err);
return NextResponse.json(
{ error: "Internal server error" },
{
error:
"Could not analyze your GitHub activity right now. Please try again in a minute.",
},
{ status: 500 }
);
}
Expand Down
Loading
Loading