Skip to content
Open
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
108 changes: 108 additions & 0 deletions src/app/api/local-coding/keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { randomBytes, createHash } from "crypto";

export const dynamic = "force-dynamic";

function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { data: keys } = await supabaseAdmin
.from("local_coding_api_keys")
.select("id, name, last_used_at, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });

return Response.json({ keys: keys || [] });
}

export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

let body: { name?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

const name = body.name?.trim();
if (!name) {
return Response.json({ error: "Name is required" }, { status: 400 });
}

const apiKey = randomBytes(24).toString("base64url");
const apiKeyHash = hashApiKey(apiKey);

const { data: keyRecord, error } = await supabaseAdmin
.from("local_coding_api_keys")
.insert({
user_id: user.id,
api_key: apiKeyHash,
name,
})
.select("id, name, last_used_at, created_at")
.single();

if (error) {
console.error("Error creating API key:", error);
return Response.json(
{ error: "Failed to create API key" },
{ status: 500 }
);
}

return Response.json({
key: { ...keyRecord, api_key: apiKey },
message: "Store this API key securely. It will not be shown again.",
});
}

export async function DELETE(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { searchParams } = new URL(req.url);
const keyId = searchParams.get("id");

if (!keyId) {
return Response.json({ error: "Key ID is required" }, { status: 400 });
}

const { error } = await supabaseAdmin
.from("local_coding_api_keys")
.delete()
.eq("id", keyId)
.eq("user_id", user.id);

if (error) {
console.error("Error deleting API key:", error);
return Response.json({ error: "Failed to delete key" }, { status: 500 });
}

return Response.json({ success: true });
}
73 changes: 73 additions & 0 deletions src/app/api/local-coding/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

const ALLOWED_DAYS = [7, 30, 90];
const DEFAULT_DAYS = 30;

function validateDays(days: number): number {
if (ALLOWED_DAYS.includes(days)) {
return days;
}
return DEFAULT_DAYS;
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { searchParams } = new URL(req.url);
const rawDays = parseInt(searchParams.get("days") || "30", 10);
const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays);
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
const fromDateStr = fromDate.toISOString().slice(0, 10);

const { data: sessions } = await supabaseAdmin
.from("local_coding_sessions")
.select("*")
.eq("user_id", user.id)
.gte("date", fromDateStr)
.order("date", { ascending: false });

if (!sessions || sessions.length === 0) {
return Response.json({
dailyData: [],
totals: {
totalSeconds: 0,
totalDays: 0,
avgSecondsPerDay: 0,
},
hasData: false,
});
}

const dailyData = sessions.map((s) => ({
date: s.date,
totalSeconds: s.total_seconds,
fileCount: s.file_count,
projectCount: s.project_count,
}));

const totalSeconds = dailyData.reduce((sum, d) => sum + d.totalSeconds, 0);
const totalDays = dailyData.length;

return Response.json({
dailyData,
totals: {
totalSeconds,
totalDays,
avgSecondsPerDay: Math.round(totalSeconds / totalDays),
},
hasData: true,
});
}
167 changes: 167 additions & 0 deletions src/app/api/local-coding/sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { NextRequest } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
import { createHash } from "crypto";

export const dynamic = "force-dynamic";

const MAX_SESSIONS_PER_REQUEST = 100;
const MAX_SESSIONS_PER_USER = 365;
const ALLOWED_DAYS = [7, 30, 90];
const DEFAULT_DAYS = 30;

function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}

async function authenticateApiKey(apiKey: string): Promise<string | null> {
const keyHash = hashApiKey(apiKey);

const { data: keyRecord } = await supabaseAdmin
.from("local_coding_api_keys")
.select("user_id")
.eq("api_key_hash", keyHash)
.single();

if (!keyRecord) {
return null;
}

await supabaseAdmin
.from("local_coding_api_keys")
.update({ last_used_at: new Date().toISOString() })
.eq("api_key_hash", keyHash);

return keyRecord.user_id;
}

function validateDays(days: number): number {
if (ALLOWED_DAYS.includes(days)) {
return days;
}
return DEFAULT_DAYS;
}

interface SessionData {
date: string;
totalSeconds: number;
fileCount: number;
projectCount: number;
}

export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return Response.json({ error: "API key required" }, { status: 401 });
}

const apiKey = authHeader.slice(7);
const userId = await authenticateApiKey(apiKey);

if (!userId) {
return Response.json({ error: "Invalid API key" }, { status: 401 });
}

let body: { sessions?: SessionData[] };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

const sessions = body.sessions;
if (!sessions || !Array.isArray(sessions) || sessions.length === 0) {
return Response.json(
{ error: "Sessions array is required" },
{ status: 400 }
);
}

if (sessions.length > MAX_SESSIONS_PER_REQUEST) {
return Response.json(
{ error: `Too many sessions. Maximum ${MAX_SESSIONS_PER_REQUEST} per request.` },
{ status: 400 }
);
}

const { count: existingCount } = await supabaseAdmin
.from("local_coding_sessions")
.select("id", { count: "exact", head: true })
.eq("user_id", userId);

const newSessions = sessions.filter((s) => {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(s.date)) {
return false;
}
if (typeof s.totalSeconds !== "number" || s.totalSeconds < 0) {
return false;
}
return true;
});

if (newSessions.length !== sessions.length) {
return Response.json(
{ error: "Invalid session data found in array" },
{ status: 400 }
);
}

if ((existingCount || 0) + newSessions.length > MAX_SESSIONS_PER_USER) {
return Response.json(
{ error: `Session limit reached. Maximum ${MAX_SESSIONS_PER_USER} sessions per user.` },
{ status: 400 }
);
}

const records = newSessions.map((session) => ({
user_id: userId,
date: session.date,
total_seconds: session.totalSeconds,
file_count: session.fileCount || 0,
project_count: session.projectCount || 0,
}));

for (const record of records) {
await supabaseAdmin
.from("local_coding_sessions")
.upsert(record, { onConflict: "user_id,date" });
}

return Response.json({
success: true,
synced: records.length,
message: "Sessions synced successfully",
});
}

export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return Response.json({ error: "API key required" }, { status: 401 });
}

const apiKey = authHeader.slice(7);
const userId = await authenticateApiKey(apiKey);

if (!userId) {
return Response.json({ error: "Invalid API key" }, { status: 401 });
}

const { searchParams } = new URL(req.url);
const rawDays = parseInt(searchParams.get("days") || "30", 10);
const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays);
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
const fromDateStr = fromDate.toISOString().slice(0, 10);

const { data: sessions } = await supabaseAdmin
.from("local_coding_sessions")
.select("*")
.eq("user_id", userId)
.gte("date", fromDateStr)
.order("date", { ascending: false });

return Response.json({ sessions: sessions || [] });
}
5 changes: 3 additions & 2 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard";
import ExportButton from "@/components/ExportButton";
import Link from "next/link";
import PersonalRecords from "@/components/PersonalRecords";
import LocalCodingTime from "@/components/LocalCodingTime";
import { authOptions } from "@/lib/auth";
import { cookies } from "next/headers";
import { getServerSession } from "next-auth";
Expand Down Expand Up @@ -57,7 +58,7 @@ export default async function DashboardPage() {
<PersonalRecords />
</div>

{/* Row 1: Contribution graph + Streak + Friend Comparison */}
{/* Row 1: Contribution graph + Streak + Local Coding Time */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<ContributionGraph />
Expand All @@ -68,7 +69,7 @@ export default async function DashboardPage() {

<div className="flex flex-col gap-6">
<StreakTracker />
<FriendComparison />
<LocalCodingTime />
</div>
</div>

Expand Down
Loading
Loading