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
48 changes: 48 additions & 0 deletions src/app/api/mentors/requests/[id]/reply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { getFreshAuthenticatedUser } from "@/lib/auth/authorization";
import { replyMentorRequest } from "@/lib/mentorship/service";
import { z } from "zod";

const replySchema = z.object({
reply: z.string().min(2, "Reply cannot be empty"),
status: z.enum(["open", "in_progress", "resolved", "closed"]).default("in_progress"),
});

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const freshUser = await getFreshAuthenticatedUser(request);
if (!freshUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const isMentorOrAdmin = freshUser.role === "admin" || freshUser.role === "maintainer";
if (!isMentorOrAdmin) {
return NextResponse.json(
{ error: "Only mentors or maintainers can reply to mentorship requests." },
{ status: 403 }
);
}

const { id } = await params;

try {
const body = await request.json();
const validated = replySchema.parse(body);

const updated = await replyMentorRequest({
requestId: id,
mentorId: freshUser.id,
reply: validated.reply,
status: validated.status,
});

return NextResponse.json({ success: true, request: updated });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || "Failed to reply to mentor request" },
{ status: 400 }
);
}
}
58 changes: 58 additions & 0 deletions src/app/api/mentors/requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getFreshAuthenticatedUser } from "@/lib/auth/authorization";
import { createMentorRequest, getMentorRequestsForUser } from "@/lib/mentorship/service";
import { z } from "zod";

const createRequestSchema = z.object({
message: z.string().min(5, "Message must be at least 5 characters"),
issueId: z.string().min(1, "Valid issue ID is required"),
mentorId: z.string().optional(),
});

export async function GET(request: NextRequest) {
const freshUser = await getFreshAuthenticatedUser(request);
if (!freshUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const isMaintainer = freshUser.role === "admin" || freshUser.role === "maintainer";

try {
const requests = await getMentorRequestsForUser(freshUser.id, isMaintainer);
return NextResponse.json({ requests });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

export async function POST(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const session = await verifySessionToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}

try {
const body = await request.json();
const validated = createRequestSchema.parse(body);

const mentorRequest = await createMentorRequest({
studentId: session.id,
message: validated.message,
issueId: validated.issueId,
mentorId: validated.mentorId,
});

return NextResponse.json({ success: true, request: mentorRequest });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || "Failed to create mentor request" },
{ status: 400 }
);
}
}
27 changes: 27 additions & 0 deletions src/app/api/notifications/[id]/read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { markNotificationAsRead } from "@/lib/notifications/service";

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const session = await verifySessionToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}

const { id } = await params;

try {
const updated = await markNotificationAsRead(id, session.id);
return NextResponse.json({ success: true, notification: updated });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
22 changes: 22 additions & 0 deletions src/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getUserNotifications } from "@/lib/notifications/service";

export async function GET(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const session = await verifySessionToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}

try {
const notifications = await getUserNotifications(session.id);
return NextResponse.json({ notifications });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
41 changes: 37 additions & 4 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getDb, schema } from "@/lib/db";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, or, desc } from "drizzle-orm";
import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules";
import { recommendIssues } from "@/lib/recommendations/engine";
import { expireOverdueClaims } from "@/lib/issues/claims";
Expand Down Expand Up @@ -101,11 +101,44 @@ export default async function DashboardPage() {
const profile = userProfiles[0];
const mergedCount = userContributions.filter((c: any) => c.state === "merged").length;

// Real database metrics for progression calculation
const completedClaims = await db
.select({ id: schema.issueClaims.id })
.from(schema.issueClaims)
.where(
and(
eq(schema.issueClaims.userId, sessionUser.id),
eq(schema.issueClaims.status, "completed")
)
);

const userReviews = await db
.select({ id: schema.pullRequestReviews.id })
.from(schema.pullRequestReviews)
.where(
and(
eq(schema.pullRequestReviews.reviewerGithubId, sessionUser.githubId),
or(
eq(schema.pullRequestReviews.reviewState, "approved"),
eq(schema.pullRequestReviews.reviewState, "changes_requested")
)
)
);

const userOpenedPrs = await db
.select({ id: schema.pullRequests.id })
.from(schema.pullRequests)
.where(eq(schema.pullRequests.userId, sessionUser.id));

const issuesResolved = completedClaims.length;
const reviewsCompleted = userReviews.length;
const prsOpened = Math.max(userContributions.length, userOpenedPrs.length);

const progression = evaluateProgression(sessionUser.level as any, {
prsOpened: userContributions.length,
prsOpened,
prsMerged: mergedCount,
issuesResolved: 0,
reviewsCompleted: 0,
issuesResolved,
reviewsCompleted,
projectsContributedCount: new Set(userContributions.map((c: any) => c.projectId)).size,
isOnboarded: sessionUser.isOnboarded,
});
Expand Down
78 changes: 53 additions & 25 deletions src/lib/founding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,35 +49,63 @@ export async function claimFoundingMembership(
};
}

// 2. Determine the next sequential member number
const maxResult = await db
.select({
maxNumber: sql<number>`COALESCE(MAX(${schema.foundingMembers.memberNumber}), 0)`,
})
.from(schema.foundingMembers);
// 2. Determine the next sequential member number atomically with retry loop
const foundingId = `founding_${crypto.randomUUID()}`;
const now = new Date();
let nextNumber = 0;
let attempts = 0;

const nextNumber = Number(maxResult[0]?.maxNumber || 0) + 1;
while (attempts < 5) {
attempts++;
const maxResult = await db
.select({
maxNumber: sql<number>`COALESCE(MAX(${schema.foundingMembers.memberNumber}), 0)`,
})
.from(schema.foundingMembers);

if (nextNumber > 1000) {
return {
claimed: false,
reason: "cohort_full",
};
}
nextNumber = Number(maxResult[0]?.maxNumber || 0) + 1;

const foundingId = `founding_${crypto.randomUUID()}`;
const now = new Date();
if (nextNumber > 1000) {
return {
claimed: false,
reason: "cohort_full",
};
}

// 3. Insert into founding_members
await db.insert(schema.foundingMembers).values({
id: foundingId,
userId: params.userId,
memberNumber: nextNumber,
status: "active",
firstPrId: params.contributionId,
verifiedAt: now,
createdAt: now,
});
try {
await db.insert(schema.foundingMembers).values({
id: foundingId,
userId: params.userId,
memberNumber: nextNumber,
status: "active",
firstPrId: params.contributionId,
verifiedAt: now,
createdAt: now,
});
break; // Successfully reserved sequential number
} catch (err: any) {
// If concurrent collision on unique memberNumber, re-attempt
if (err.message?.includes("member_number") || err.code === "23505") {
continue;
}
// If user was claimed in parallel
if (err.message?.includes("user_id") || err.message?.includes("already exists")) {
const parallel = await db
.select()
.from(schema.foundingMembers)
.where(eq(schema.foundingMembers.userId, params.userId))
.limit(1);
if (parallel.length > 0) {
return {
claimed: true,
memberNumber: parallel[0].memberNumber,
reason: "already_claimed",
};
}
}
throw err;
}
}

// 4. Mirror foundingNumber to users table
await db
Expand Down
Loading
Loading