diff --git a/src/app/api/mentors/requests/[id]/reply/route.ts b/src/app/api/mentors/requests/[id]/reply/route.ts new file mode 100644 index 0000000..fc67a50 --- /dev/null +++ b/src/app/api/mentors/requests/[id]/reply/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/mentors/requests/route.ts b/src/app/api/mentors/requests/route.ts new file mode 100644 index 0000000..84c6446 --- /dev/null +++ b/src/app/api/mentors/requests/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/notifications/[id]/read/route.ts b/src/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..be95f37 --- /dev/null +++ b/src/app/api/notifications/[id]/read/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..e8a61c4 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -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 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index dcf17bc..c08ca0d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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"; @@ -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, }); diff --git a/src/lib/founding/index.ts b/src/lib/founding/index.ts index b52f9a7..6c05a01 100644 --- a/src/lib/founding/index.ts +++ b/src/lib/founding/index.ts @@ -49,35 +49,63 @@ export async function claimFoundingMembership( }; } - // 2. Determine the next sequential member number - const maxResult = await db - .select({ - maxNumber: sql`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`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 diff --git a/src/lib/mentorship/service.test.ts b/src/lib/mentorship/service.test.ts new file mode 100644 index 0000000..493707a --- /dev/null +++ b/src/lib/mentorship/service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { + createMentorRequest, + getMentorRequestsForUser, + replyMentorRequest, +} from "./service"; +import { eq } from "drizzle-orm"; + +describe("Mentorship Service", () => { + const studentId = "usr_mentor_student_01"; + const mentorId = "usr_mentor_lead_01"; + const projectId = "proj_mentor_test_repo"; + const issueId = "iss_mentor_test_issue"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + const db = await getDb(); + + // 1. Seed users + await db.insert(schema.users).values([ + { + id: studentId, + githubId: 554433, + githubUsername: "learner_dan", + role: "contributor", + level: "level_1", + isOnboarded: true, + }, + { + id: mentorId, + githubId: 554434, + githubUsername: "mentor_sarah", + role: "maintainer", + level: "level_5", + isOnboarded: true, + }, + ]); + + // 2. Seed project + await db.insert(schema.projects).values({ + id: projectId, + name: "mentor-test-repo", + slug: "mentor-test-repo", + githubRepo: "TechNexusOrg/mentor-test-repo", + description: "Test repo for mentorship requests", + primaryLanguage: "TypeScript", + contributionEnabled: true, + }); + + // 3. Seed issue + await db.insert(schema.issues).values({ + id: issueId, + projectId, + githubIssueId: 88891, + githubIssueNumber: 5, + title: "Set up test container", + state: "open", + htmlUrl: "https://github.com/TechNexusOrg/mentor-test-repo/issues/5", + difficulty: "beginner", + }); + }); + + it("submits assistance request with open status", async () => { + const req = await createMentorRequest({ + studentId, + issueId, + message: "Need guidance on setting up local Docker container for tests", + }); + + expect(req).toBeDefined(); + expect(req.status).toBe("open"); + expect(req.studentId).toBe(studentId); + expect(req.issueId).toBe(issueId); + }); + + it("lists open requests for mentors", async () => { + const requests = await getMentorRequestsForUser(mentorId, true); + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests[0].studentId).toBe(studentId); + }); + + it("allows mentor to reply and sends notification to student", async () => { + const db = await getDb(); + const requests = await getMentorRequestsForUser(mentorId, true); + const target = requests[0]; + + const updated = await replyMentorRequest({ + requestId: target.id, + mentorId, + reply: "Check out the scripts/setup-docker.sh script in the root directory!", + status: "resolved", + }); + + expect(updated.status).toBe("resolved"); + expect(updated.mentorId).toBe(mentorId); + expect(updated.reply).toContain("setup-docker.sh"); + + // Check notification was sent to student + const notifs = await db + .select() + .from(schema.notifications) + .where(eq(schema.notifications.userId, studentId)); + + expect(notifs.length).toBeGreaterThanOrEqual(1); + expect(notifs[0].title).toBe("Mentor Responded"); + }); +}); diff --git a/src/lib/mentorship/service.ts b/src/lib/mentorship/service.ts new file mode 100644 index 0000000..9ba12e4 --- /dev/null +++ b/src/lib/mentorship/service.ts @@ -0,0 +1,144 @@ +import { getDb, schema } from "@/lib/db"; +import { eq, or, desc } from "drizzle-orm"; +import { createNotification } from "@/lib/notifications/service"; + +export interface CreateMentorRequestParams { + studentId: string; + issueId: string; + mentorId?: string; + message: string; +} + +export interface ReplyMentorRequestParams { + requestId: string; + mentorId: string; + reply: string; + status: "open" | "in_progress" | "resolved" | "closed"; +} + +/** + * Creates a new mentor request for assistance on an issue. + */ +export async function createMentorRequest( + params: CreateMentorRequestParams, + database?: any +) { + const db = database || (await getDb()); + const requestId = `mreq_${crypto.randomUUID()}`; + const now = new Date(); + + const [request] = await db + .insert(schema.mentorRequests) + .values({ + id: requestId, + studentId: params.studentId, + issueId: params.issueId, + mentorId: params.mentorId || null, + message: params.message, + status: "open", + createdAt: now, + }) + .returning(); + + return request; +} + +/** + * Fetches mentorship requests relevant to a user (as student or assigned mentor). + */ +export async function getMentorRequestsForUser( + userId: string, + isMaintainer: boolean, + database?: any +) { + const db = database || (await getDb()); + + if (isMaintainer) { + // Mentors/maintainers see open requests without mentor assigned, or assigned to them + return db + .select({ + id: schema.mentorRequests.id, + studentId: schema.mentorRequests.studentId, + mentorId: schema.mentorRequests.mentorId, + issueId: schema.mentorRequests.issueId, + message: schema.mentorRequests.message, + reply: schema.mentorRequests.reply, + status: schema.mentorRequests.status, + createdAt: schema.mentorRequests.createdAt, + studentUsername: schema.users.githubUsername, + studentDisplayName: schema.users.displayName, + studentAvatar: schema.users.avatarUrl, + }) + .from(schema.mentorRequests) + .innerJoin(schema.users, eq(schema.mentorRequests.studentId, schema.users.id)) + .where( + or( + eq(schema.mentorRequests.mentorId, userId), + eq(schema.mentorRequests.status, "open") + ) + ) + .orderBy(desc(schema.mentorRequests.createdAt)); + } + + // Regular contributors see their own submitted requests + return db + .select({ + id: schema.mentorRequests.id, + studentId: schema.mentorRequests.studentId, + mentorId: schema.mentorRequests.mentorId, + issueId: schema.mentorRequests.issueId, + message: schema.mentorRequests.message, + reply: schema.mentorRequests.reply, + status: schema.mentorRequests.status, + createdAt: schema.mentorRequests.createdAt, + }) + .from(schema.mentorRequests) + .where(eq(schema.mentorRequests.studentId, userId)) + .orderBy(desc(schema.mentorRequests.createdAt)); +} + +/** + * Responds to a mentorship request. + */ +export async function replyMentorRequest( + params: ReplyMentorRequestParams, + database?: any +) { + const db = database || (await getDb()); + const now = new Date(); + + const [existing] = await db + .select() + .from(schema.mentorRequests) + .where(eq(schema.mentorRequests.id, params.requestId)) + .limit(1); + + if (!existing) { + throw new Error("Mentorship request not found."); + } + + const [updated] = await db + .update(schema.mentorRequests) + .set({ + mentorId: params.mentorId, + reply: params.reply, + status: params.status, + resolvedAt: params.status === "resolved" ? now : existing.resolvedAt, + }) + .where(eq(schema.mentorRequests.id, params.requestId)) + .returning(); + + // Notify student of response + await createNotification( + { + userId: existing.studentId, + type: "system", + title: "Mentor Responded", + message: `A mentor responded to your assistance request: "${params.reply.substring(0, 100)}"`, + linkUrl: `/issues/${existing.issueId}`, + }, + db + ); + + return updated; +} diff --git a/src/lib/notifications/service.test.ts b/src/lib/notifications/service.test.ts new file mode 100644 index 0000000..6bd0c00 --- /dev/null +++ b/src/lib/notifications/service.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { + createNotification, + getUserNotifications, + markNotificationAsRead, +} from "./service"; + +describe("Notifications Service", () => { + const userId = "usr_notif_test_user"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + const db = await getDb(); + + await db.insert(schema.users).values({ + id: userId, + githubId: 665544, + githubUsername: "notif_tester", + role: "contributor", + level: "level_1", + isOnboarded: true, + }); + }); + + it("creates notification with unread status", async () => { + const notif = await createNotification({ + userId, + type: "credential_issued", + title: "New Credential Minted", + message: "You earned the First PR Merged credential!", + linkUrl: "/verify/cred_123", + }); + + expect(notif).toBeDefined(); + expect(notif.isRead).toBe(false); + expect(notif.type).toBe("credential_issued"); + expect(notif.userId).toBe(userId); + }); + + it("retrieves user notifications ordered by creation time", async () => { + const list = await getUserNotifications(userId); + expect(list.length).toBeGreaterThanOrEqual(1); + expect(list[0].userId).toBe(userId); + }); + + it("marks a notification as read", async () => { + const list = await getUserNotifications(userId); + const firstNotif = list[0]; + + const updated = await markNotificationAsRead(firstNotif.id, userId); + expect(updated.isRead).toBe(true); + }); +}); diff --git a/src/lib/notifications/service.ts b/src/lib/notifications/service.ts new file mode 100644 index 0000000..b5d31cf --- /dev/null +++ b/src/lib/notifications/service.ts @@ -0,0 +1,78 @@ +import { getDb, schema } from "@/lib/db"; +import { eq, desc, and } from "drizzle-orm"; + +export interface CreateNotificationParams { + userId: string; + type: "credential_issued" | "claim_expiring" | "pr_review" | "system"; + title: string; + message: string; + linkUrl?: string; +} + +/** + * Creates an in-app notification for a contributor. + */ +export async function createNotification( + params: CreateNotificationParams, + database?: any +) { + const db = database || (await getDb()); + const notificationId = `notif_${crypto.randomUUID()}`; + const now = new Date(); + + const [notification] = await db + .insert(schema.notifications) + .values({ + id: notificationId, + userId: params.userId, + type: params.type, + title: params.title, + message: params.message, + linkUrl: params.linkUrl || null, + isRead: false, + createdAt: now, + }) + .returning(); + + return notification; +} + +/** + * Fetches recent notifications for a user. + */ +export async function getUserNotifications( + userId: string, + limit = 20, + database?: any +) { + const db = database || (await getDb()); + return db + .select() + .from(schema.notifications) + .where(eq(schema.notifications.userId, userId)) + .orderBy(desc(schema.notifications.createdAt)) + .limit(limit); +} + +/** + * Marks a notification as read. + */ +export async function markNotificationAsRead( + notificationId: string, + userId: string, + database?: any +) { + const db = database || (await getDb()); + const [updated] = await db + .update(schema.notifications) + .set({ isRead: true }) + .where( + and( + eq(schema.notifications.id, notificationId), + eq(schema.notifications.userId, userId) + ) + ) + .returning(); + + return updated; +}