From 48aceabace8d043775602c30dfb45a2311d8fd3d Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:36:45 +0530 Subject: [PATCH] feat(sprint-2): implement issue detail page, claims engine, and contribution workspace --- drizzle/0002_issue_body.sql | 1 + src/app/api/issues/[id]/claim/route.ts | 34 ++ src/app/api/issues/[id]/release/route.ts | 32 ++ src/app/dashboard/page.tsx | 4 + src/app/issues/IssueMarketplace.tsx | 39 +- src/app/issues/[id]/IssueWorkspaceClient.tsx | 527 +++++++++++++++++++ src/app/issues/[id]/page.tsx | 129 +++++ src/lib/db/schema.ts | 1 + src/lib/github/issues.ts | 2 + src/lib/issues/claims.test.ts | 166 ++++++ src/lib/issues/claims.ts | 321 +++++++++++ vitest.config.ts | 2 + 12 files changed, 1243 insertions(+), 15 deletions(-) create mode 100644 drizzle/0002_issue_body.sql create mode 100644 src/app/api/issues/[id]/claim/route.ts create mode 100644 src/app/api/issues/[id]/release/route.ts create mode 100644 src/app/issues/[id]/IssueWorkspaceClient.tsx create mode 100644 src/app/issues/[id]/page.tsx create mode 100644 src/lib/issues/claims.test.ts create mode 100644 src/lib/issues/claims.ts diff --git a/drizzle/0002_issue_body.sql b/drizzle/0002_issue_body.sql new file mode 100644 index 0000000..c2fc99a --- /dev/null +++ b/drizzle/0002_issue_body.sql @@ -0,0 +1 @@ +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "body" text; diff --git a/src/app/api/issues/[id]/claim/route.ts b/src/app/api/issues/[id]/claim/route.ts new file mode 100644 index 0000000..f8e1c17 --- /dev/null +++ b/src/app/api/issues/[id]/claim/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { claimIssue } from "@/lib/issues/claims"; + +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: "Authentication required to claim issues." }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid or expired session." }, { status: 401 }); + } + + const { id } = await params; + + try { + const result = await claimIssue({ + issueId: id, + userId: sessionUser.id, + }); + + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { error: err.message || "Failed to claim issue." }, + { status: 400 } + ); + } +} diff --git a/src/app/api/issues/[id]/release/route.ts b/src/app/api/issues/[id]/release/route.ts new file mode 100644 index 0000000..b367f87 --- /dev/null +++ b/src/app/api/issues/[id]/release/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getFreshAuthenticatedUser } from "@/lib/auth/authorization"; +import { releaseIssueClaim } from "@/lib/issues/claims"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const freshUser = await getFreshAuthenticatedUser(request); + if (!freshUser) { + return NextResponse.json({ error: "Authentication required to release claim." }, { status: 401 }); + } + + const isAdmin = freshUser.role === "admin" || freshUser.role === "maintainer"; + + const { id } = await params; + + try { + const result = await releaseIssueClaim({ + issueId: id, + userId: freshUser.id, + isAdmin, + }); + + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { error: err.message || "Failed to release claim." }, + { status: 400 } + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9e9446b..dcf17bc 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import { getDb, schema } from "@/lib/db"; import { eq, and, desc } from "drizzle-orm"; import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; import { recommendIssues } from "@/lib/recommendations/engine"; +import { expireOverdueClaims } from "@/lib/issues/claims"; import { SyncContributionsButton } from "./SyncContributionsButton"; import { ProgressionPath } from "./ProgressionPath"; @@ -55,6 +56,9 @@ export default async function DashboardPage() { .where(eq(schema.contributions.userId, sessionUser.id)) .orderBy(desc(schema.contributions.createdAt)); + // Expire overdue claims across system + await expireOverdueClaims(db); + // Fetch active issue claims const activeClaims = await db .select({ diff --git a/src/app/issues/IssueMarketplace.tsx b/src/app/issues/IssueMarketplace.tsx index f35927f..cb8e58f 100644 --- a/src/app/issues/IssueMarketplace.tsx +++ b/src/app/issues/IssueMarketplace.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import Link from "next/link"; export interface MarketplaceIssue { id: string; @@ -188,9 +189,9 @@ export function IssueMarketplace({ initialIssues }: { initialIssues: Marketplace

- + {issue.title} - +

{issue.bodySnippet && ( @@ -214,20 +215,28 @@ export function IssueMarketplace({ initialIssues }: { initialIssues: Marketplace
- - View Issue - ↗ - - {issue.estimatedEffort && ( - - Est: {issue.estimatedEffort} - - )} + Start Contribution + → + +
+ + GitHub ↗ + + {issue.estimatedEffort && ( + + • {issue.estimatedEffort} + + )} +
))} diff --git a/src/app/issues/[id]/IssueWorkspaceClient.tsx b/src/app/issues/[id]/IssueWorkspaceClient.tsx new file mode 100644 index 0000000..bfda9f6 --- /dev/null +++ b/src/app/issues/[id]/IssueWorkspaceClient.tsx @@ -0,0 +1,527 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { ActiveIssueClaim } from "@/lib/issues/claims"; + +interface IssueWorkspaceClientProps { + issue: { + id: string; + title: string; + body: string | null; + bodySnippet: string | null; + state: "open" | "closed"; + htmlUrl: string; + labels: string[]; + difficulty: "beginner" | "intermediate" | "advanced"; + estimatedEffort: string | null; + skillsRequired: string[]; + isGoodFirstIssue: boolean; + isHelpWanted: boolean; + githubIssueNumber: number; + }; + project: { + id: string; + name: string; + githubRepo: string; + description: string; + primaryLanguage: string; + contributionEnabled: boolean; + }; + currentUser: { + id: string; + githubUsername: string; + displayName: string | null; + avatarUrl: string | null; + level: string; + } | null; + initialActiveClaim: ActiveIssueClaim | null; + userActiveClaimsCount: number; + userMaxClaims: number; +} + +export function IssueWorkspaceClient({ + issue, + project, + currentUser, + initialActiveClaim, + userActiveClaimsCount, + userMaxClaims, +}: IssueWorkspaceClientProps) { + const router = useRouter(); + const [activeClaim, setActiveClaim] = useState(initialActiveClaim); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [copiedIndex, setCopiedIndex] = useState(null); + + const isClaimedByMe = Boolean(currentUser && activeClaim && activeClaim.userId === currentUser.id); + const isClaimedByOther = Boolean(activeClaim && (!currentUser || activeClaim.userId !== currentUser.id)); + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedIndex(id); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + const handleClaim = async () => { + if (!currentUser) { + window.location.href = "/api/auth/github"; + return; + } + + setLoading(true); + setError(null); + setSuccessMessage(null); + + try { + const res = await fetch(`/api/issues/${issue.id}/claim`, { + method: "POST", + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to claim issue."); + } + + setSuccessMessage("Issue successfully claimed! Your contribution workspace is now active."); + setActiveClaim({ + id: data.claim.id, + issueId: issue.id, + userId: currentUser.id, + status: "active", + claimedAt: new Date(data.claim.claimedAt), + expiresAt: new Date(data.claim.expiresAt), + user: { + id: currentUser.id, + githubUsername: currentUser.githubUsername, + displayName: currentUser.displayName, + avatarUrl: currentUser.avatarUrl, + }, + }); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleRelease = async () => { + if (!confirm("Are you sure you want to release this issue claim? Other contributors will be able to claim it.")) { + return; + } + + setLoading(true); + setError(null); + setSuccessMessage(null); + + try { + const res = await fetch(`/api/issues/${issue.id}/release`, { + method: "POST", + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to release claim."); + } + + setSuccessMessage("Issue claim released."); + setActiveClaim(null); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const daysRemaining = activeClaim + ? Math.max( + 0, + Math.ceil( + (new Date(activeClaim.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ) + ) + : 0; + + return ( +
+ {/* Navigation & Header */} +
+
+ + ← + Back to Marketplace + + / + {project.name} + / + #{issue.githubIssueNumber} +
+ +
+
+
+ + {issue.difficulty} + + + {issue.isGoodFirstIssue && ( + + good first issue + + )} + + {issue.estimatedEffort && ( + + ⏱ {issue.estimatedEffort} + + )} + + + {project.githubRepo} + +
+ +

+ {issue.title} +

+
+ + +
+
+ + {/* Notifications */} + {error && ( +
+ ✕ {error} +
+ )} + + {successMessage && ( +
+ ✓ {successMessage} +
+ )} + + {/* Main Grid: 2 Columns */} +
+ {/* Left Column: Details & Body */} +
+ {/* Issue Description */} +
+
+

+ Issue Description +

+ + Source: GitHub Issue #{issue.githubIssueNumber} + +
+ +
+ {issue.body || issue.bodySnippet || "No description provided in GitHub issue."} +
+ + {/* Labels and tags */} + {issue.labels.length > 0 && ( +
+
+ GitHub Labels +
+
+ {issue.labels.map((lbl) => ( + + {lbl} + + ))} +
+
+ )} + + {/* Required Skills */} + {issue.skillsRequired.length > 0 && ( +
+
+ Relevant Skills & Stack +
+
+ {issue.skillsRequired.map((skill) => ( + + {skill} + + ))} +
+
+ )} +
+ + {/* Acceptance Criteria & Quality Standard */} +
+

+ TechNexus Quality & Merge Requirements +

+
+
+ 1. + Code adheres to repository formatting, ESLint rules, and TypeScript strict mode. +
+
+ 2. + Unit and integration tests pass cleanly without regressions. +
+
+ 3. + Pull request references Fixes #{issue.githubIssueNumber} in the PR description body. +
+
+ 4. + Maintainer review is approved before merge. +
+
+
+
+ + {/* Right Column: Claim & Contribution Workspace */} +
+ {/* Claim Action Box */} +
+
+

+ Claim Status +

+

+ Claiming reserves this issue for 7 days while you implement the solution. +

+
+ + {/* Status Indicator */} + {isClaimedByMe ? ( +
+
+ + ● ACTIVE CLAIM (YOU) + + + {daysRemaining} day{daysRemaining === 1 ? "" : "s"} left + +
+
+ You are actively working on this issue. Follow the guided steps below to submit your PR. +
+ +
+ ) : isClaimedByOther && activeClaim ? ( +
+
+ {activeClaim.user.avatarUrl ? ( + {activeClaim.user.githubUsername} + ) : ( +
+ @ +
+ )} + + Claimed by @{activeClaim.user.githubUsername} + +
+

+ Reserved for {daysRemaining} more day{daysRemaining === 1 ? "" : "s"}. If released or expired, it will become available again. +

+ +
+ ) : ( +
+
+
+ ✓ + Ready for Assignment +
+

+ This issue is open and ready to be claimed. Active claims last 7 days and can be released at any time without penalty. +

+
+ + {!currentUser ? ( + + Sign in with GitHub to Claim + → + + ) : ( +
+ + {userActiveClaimsCount >= userMaxClaims && ( +

+ Active claim limit reached ({userActiveClaimsCount}/{userMaxClaims}). Release an existing claim first. +

+ )} +
+ )} +
+ )} +
+ + {/* Guided Contribution Steps Workspace */} +
+

+ Contribution Steps +

+ +
+ {/* Step 1 */} +
+
+ + 1 + + Clone & Branch +
+
+

+ Clone the repository and create a feature branch: +

+
+
+                      git clone https://github.com/{project.githubRepo}.git{"\n"}
+                      cd {project.name.toLowerCase()}{"\n"}
+                      git checkout -b fix/issue-{issue.githubIssueNumber}
+                    
+ +
+
+
+ + {/* Step 2 */} +
+
+ + 2 + + Implement & Test +
+
+

+ Install dependencies and run test verification locally: +

+
+                    npm install{"\n"}npm test
+                  
+
+
+ + {/* Step 3 */} +
+
+ + 3 + + Open PR with Link +
+
+

+ Include this exact line in your Pull Request description: +

+
+
+                      Fixes #{issue.githubIssueNumber}
+                    
+ +
+
+
+ + {/* Step 4 */} +
+
+ + 4 + + Merge & Verification +
+
+

+ Once merged, GitHub webhooks automatically detect the closure, update your Contributor Passport, and mint your verifiable credential. +

+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx new file mode 100644 index 0000000..48bc5ea --- /dev/null +++ b/src/app/issues/[id]/page.tsx @@ -0,0 +1,129 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { getDb, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { + getActiveClaimForIssue, + getUserActiveClaims, + getMaxActiveClaimsForLevel, +} from "@/lib/issues/claims"; +import { IssueWorkspaceClient } from "./IssueWorkspaceClient"; + +export const dynamic = "force-dynamic"; + +interface IssueDetailPageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: IssueDetailPageProps) { + const { id } = await params; + const db = await getDb(); + + const [issue] = await db + .select({ + title: schema.issues.title, + projectName: schema.projects.name, + }) + .from(schema.issues) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where(eq(schema.issues.id, id)) + .limit(1); + + if (!issue) { + return { + title: "Issue Not Found — TechNexusOrg", + }; + } + + return { + title: `${issue.title} — ${issue.projectName} — TechNexusOrg`, + description: `Claim and contribute to ${issue.title} on TechNexusOrg. Real issues, maintainer reviews, and verifiable credentials.`, + }; +} + +export default async function IssueDetailPage({ params }: IssueDetailPageProps) { + const { id } = await params; + const db = await getDb(); + + const [issueRecord] = await db + .select({ + id: schema.issues.id, + title: schema.issues.title, + body: schema.issues.body, + bodySnippet: schema.issues.bodySnippet, + state: schema.issues.state, + htmlUrl: schema.issues.htmlUrl, + labels: schema.issues.labels, + difficulty: schema.issues.difficulty, + estimatedEffort: schema.issues.estimatedEffort, + skillsRequired: schema.issues.skillsRequired, + isGoodFirstIssue: schema.issues.isGoodFirstIssue, + isHelpWanted: schema.issues.isHelpWanted, + githubIssueNumber: schema.issues.githubIssueNumber, + projectId: schema.projects.id, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + projectDescription: schema.projects.description, + primaryLanguage: schema.projects.primaryLanguage, + contributionEnabled: schema.projects.contributionEnabled, + }) + .from(schema.issues) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where(eq(schema.issues.id, id)) + .limit(1); + + if (!issueRecord) { + notFound(); + } + + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + const sessionUser = token ? await verifySessionToken(token) : null; + + const activeClaim = await getActiveClaimForIssue(id); + const userClaims = sessionUser ? await getUserActiveClaims(sessionUser.id) : []; + const maxClaims = sessionUser ? getMaxActiveClaimsForLevel(sessionUser.level) : 1; + + return ( + + ); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index a01221a..b217842 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -146,6 +146,7 @@ export const issues = pgTable( githubIssueId: integer("github_issue_id").notNull().unique(), githubIssueNumber: integer("github_issue_number").notNull(), title: text("title").notNull(), + body: text("body"), bodySnippet: text("body_snippet"), state: text("state", { enum: ["open", "closed"] }) .default("open") diff --git a/src/lib/github/issues.ts b/src/lib/github/issues.ts index dec6c9a..abc3118 100644 --- a/src/lib/github/issues.ts +++ b/src/lib/github/issues.ts @@ -184,6 +184,7 @@ export async function syncRepositoryIssues( .update(schema.issues) .set({ title: ghIssue.title, + body: ghIssue.body || null, bodySnippet: ghIssue.body ? ghIssue.body.substring(0, 300) : null, state: ghIssue.state === "closed" ? "closed" : "open", labels: labelStrings, @@ -202,6 +203,7 @@ export async function syncRepositoryIssues( githubIssueId: ghIssue.id, githubIssueNumber: ghIssue.number, title: ghIssue.title, + body: ghIssue.body || null, bodySnippet: ghIssue.body ? ghIssue.body.substring(0, 300) : null, state: "open", htmlUrl: ghIssue.html_url, diff --git a/src/lib/issues/claims.test.ts b/src/lib/issues/claims.test.ts new file mode 100644 index 0000000..c78d039 --- /dev/null +++ b/src/lib/issues/claims.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { + claimIssue, + releaseIssueClaim, + getActiveClaimForIssue, + expireOverdueClaims, +} from "./claims"; +import { eq } from "drizzle-orm"; + +describe("Issue Claims Engine", () => { + const user1Id = "usr_claim_test_1"; + const user2Id = "usr_claim_test_2"; + const projectId = "proj_claims_test"; + const issue1Id = "iss_claim_test_1"; + const issue2Id = "iss_claim_test_2"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + const db = await getDb(); + + // Seed test users + await db.insert(schema.users).values([ + { + id: user1Id, + githubId: 881001, + githubUsername: "student_alice", + displayName: "Alice Developer", + role: "contributor", + level: "level_1", + isOnboarded: true, + }, + { + id: user2Id, + githubId: 881002, + githubUsername: "student_bob", + displayName: "Bob Engineer", + role: "contributor", + level: "level_1", + isOnboarded: true, + }, + ]); + + // Seed test project + await db.insert(schema.projects).values({ + id: projectId, + name: "Core Platform", + slug: "core-platform-test", + githubRepo: "TechNexusOrg/core-platform-test", + description: "Official TechNexusOrg core platform test repository", + primaryLanguage: "TypeScript", + contributionEnabled: true, + }); + + // Seed test issues + await db.insert(schema.issues).values([ + { + id: issue1Id, + projectId, + githubIssueId: 991, + githubIssueNumber: 101, + title: "Implement issue claim timeout notification", + state: "open", + htmlUrl: "https://github.com/TechNexusOrg/core-platform-test/issues/101", + difficulty: "beginner", + isGoodFirstIssue: true, + }, + { + id: issue2Id, + projectId, + githubIssueId: 992, + githubIssueNumber: 102, + title: "Refactor session cache", + state: "open", + htmlUrl: "https://github.com/TechNexusOrg/core-platform-test/issues/102", + difficulty: "intermediate", + }, + ]); + }); + + it("successfully claims an available open issue", async () => { + const result = await claimIssue({ + issueId: issue1Id, + userId: user1Id, + }); + + expect(result.success).toBe(true); + expect(result.claim.status).toBe("active"); + expect(result.claim.userId).toBe(user1Id); + expect(result.claim.issueId).toBe(issue1Id); + + const activeClaim = await getActiveClaimForIssue(issue1Id); + expect(activeClaim).not.toBeNull(); + expect(activeClaim?.user.githubUsername).toBe("student_alice"); + }); + + it("prevents another user from claiming the same active issue", async () => { + await expect( + claimIssue({ + issueId: issue1Id, + userId: user2Id, + }) + ).rejects.toThrow(/currently claimed by @student_alice/); + }); + + it("prevents level_1 user from claiming more than 1 active issue", async () => { + await expect( + claimIssue({ + issueId: issue2Id, + userId: user1Id, + }) + ).rejects.toThrow(/limit of 1 active claim/); + }); + + it("allows user to release their own claim", async () => { + const result = await releaseIssueClaim({ + issueId: issue1Id, + userId: user1Id, + }); + + expect(result.success).toBe(true); + expect(result.claim.status).toBe("released"); + + const activeClaim = await getActiveClaimForIssue(issue1Id); + expect(activeClaim).toBeNull(); + }); + + it("allows another user to claim after release", async () => { + const result = await claimIssue({ + issueId: issue1Id, + userId: user2Id, + }); + + expect(result.success).toBe(true); + expect(result.claim.userId).toBe(user2Id); + + const activeClaim = await getActiveClaimForIssue(issue1Id); + expect(activeClaim?.user.githubUsername).toBe("student_bob"); + }); + + it("expires overdue claims and allows re-claiming", async () => { + const db = await getDb(); + // Simulate expired claim + await db + .update(schema.issueClaims) + .set({ + expiresAt: new Date(Date.now() - 1000 * 60), // 1 minute ago + }) + .where(eq(schema.issueClaims.issueId, issue1Id)); + + await expireOverdueClaims(db); + + const activeClaim = await getActiveClaimForIssue(issue1Id); + expect(activeClaim).toBeNull(); + + // Now user1 can claim it again + const result = await claimIssue({ + issueId: issue1Id, + userId: user1Id, + }); + expect(result.success).toBe(true); + expect(result.claim.userId).toBe(user1Id); + }); +}); diff --git a/src/lib/issues/claims.ts b/src/lib/issues/claims.ts new file mode 100644 index 0000000..b67660d --- /dev/null +++ b/src/lib/issues/claims.ts @@ -0,0 +1,321 @@ +import { getDb, schema } from "@/lib/db"; +import { eq, and, lt } from "drizzle-orm"; + +export const CLAIM_EXPIRY_DAYS = 7; + +export interface IssueClaimUser { + id: string; + githubUsername: string; + displayName: string | null; + avatarUrl: string | null; +} + +export interface ActiveIssueClaim { + id: string; + issueId: string; + userId: string; + status: "active" | "completed" | "released" | "expired" | "cancelled"; + claimedAt: Date; + expiresAt: Date; + user: IssueClaimUser; +} + +/** + * Expires any active claims that have exceeded their 7-day lifespan. + */ +export async function expireOverdueClaims(database?: any) { + const db = database || (await getDb()); + const now = new Date(); + + await db + .update(schema.issueClaims) + .set({ + status: "expired", + updatedAt: now, + }) + .where( + and( + eq(schema.issueClaims.status, "active"), + lt(schema.issueClaims.expiresAt, now) + ) + ); +} + +/** + * Returns the maximum number of active issues a user can claim concurrently based on contributor level. + * Level 1 (Observer) & Level 2 (Explorer): 1 active claim. + * Level 3+ (Builder, Architect, Maintainer): 2 active claims. + */ +export function getMaxActiveClaimsForLevel(level?: string | null): number { + if (level === "level_3" || level === "level_4" || level === "level_5") { + return 2; + } + return 1; +} + +/** + * Claims an issue for a contributor. + */ +export async function claimIssue( + params: { + issueId: string; + userId: string; + }, + database?: any +) { + const db = database || (await getDb()); + const now = new Date(); + + // Expire overdue claims across system + await expireOverdueClaims(db); + + // 1. Fetch user to verify level and status + const [user] = await db + .select({ + id: schema.users.id, + githubUsername: schema.users.githubUsername, + role: schema.users.role, + level: schema.users.level, + }) + .from(schema.users) + .where(eq(schema.users.id, params.userId)) + .limit(1); + + if (!user) { + throw new Error("Contributor account not found."); + } + + // 2. Fetch issue and project + const [issue] = await db + .select({ + id: schema.issues.id, + title: schema.issues.title, + state: schema.issues.state, + projectId: schema.issues.projectId, + githubIssueNumber: schema.issues.githubIssueNumber, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + contributionEnabled: schema.projects.contributionEnabled, + }) + .from(schema.issues) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where(eq(schema.issues.id, params.issueId)) + .limit(1); + + if (!issue) { + throw new Error("Issue not found."); + } + + if (issue.state !== "open") { + throw new Error("This issue is closed and cannot be claimed."); + } + + if (!issue.contributionEnabled) { + throw new Error("Contributions are currently disabled for this repository."); + } + + // 3. Check if issue is already claimed by someone + const existingActiveClaims = await db + .select({ + id: schema.issueClaims.id, + userId: schema.issueClaims.userId, + claimedAt: schema.issueClaims.claimedAt, + expiresAt: schema.issueClaims.expiresAt, + username: schema.users.githubUsername, + }) + .from(schema.issueClaims) + .innerJoin(schema.users, eq(schema.issueClaims.userId, schema.users.id)) + .where( + and( + eq(schema.issueClaims.issueId, params.issueId), + eq(schema.issueClaims.status, "active") + ) + ) + .limit(1); + + if (existingActiveClaims.length > 0) { + const existing = existingActiveClaims[0]; + if (existing.userId === params.userId) { + return { + success: true, + alreadyClaimedBySelf: true, + claim: existing, + }; + } + throw new Error( + `This issue is currently claimed by @${existing.username} until ${new Date( + existing.expiresAt + ).toLocaleDateString()}.` + ); + } + + // 4. Check user's active claims count limit + const userClaims = await db + .select({ id: schema.issueClaims.id }) + .from(schema.issueClaims) + .where( + and( + eq(schema.issueClaims.userId, params.userId), + eq(schema.issueClaims.status, "active") + ) + ); + + const maxAllowed = getMaxActiveClaimsForLevel(user.level); + if (userClaims.length >= maxAllowed) { + throw new Error( + `You have reached your limit of ${maxAllowed} active claim(s). Please complete or release your existing claim before claiming another issue.` + ); + } + + // 5. Create claim + const claimId = `claim_${crypto.randomUUID()}`; + const expiresAt = new Date(now.getTime() + CLAIM_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + const [newClaim] = await db + .insert(schema.issueClaims) + .values({ + id: claimId, + issueId: params.issueId, + userId: params.userId, + status: "active", + claimedAt: now, + expiresAt, + createdAt: now, + updatedAt: now, + }) + .returning(); + + return { + success: true, + alreadyClaimedBySelf: false, + claim: newClaim, + }; +} + +/** + * Releases an active claim on an issue. + */ +export async function releaseIssueClaim( + params: { + issueId: string; + userId: string; + isAdmin?: boolean; + }, + database?: any +) { + const db = database || (await getDb()); + const now = new Date(); + + // Find active claim + const [activeClaim] = await db + .select() + .from(schema.issueClaims) + .where( + and( + eq(schema.issueClaims.issueId, params.issueId), + eq(schema.issueClaims.status, "active") + ) + ) + .limit(1); + + if (!activeClaim) { + throw new Error("No active claim found for this issue."); + } + + if (activeClaim.userId !== params.userId && !params.isAdmin) { + throw new Error("You do not have permission to release this claim."); + } + + const [updated] = await db + .update(schema.issueClaims) + .set({ + status: "released", + releasedAt: now, + updatedAt: now, + }) + .where(eq(schema.issueClaims.id, activeClaim.id)) + .returning(); + + return { + success: true, + claim: updated, + }; +} + +/** + * Fetches the active claim for an issue, if any. + */ +export async function getActiveClaimForIssue( + issueId: string, + database?: any +): Promise { + const db = database || (await getDb()); + await expireOverdueClaims(db); + + const claims = await db + .select({ + id: schema.issueClaims.id, + issueId: schema.issueClaims.issueId, + userId: schema.issueClaims.userId, + status: schema.issueClaims.status, + claimedAt: schema.issueClaims.claimedAt, + expiresAt: schema.issueClaims.expiresAt, + user: { + id: schema.users.id, + githubUsername: schema.users.githubUsername, + displayName: schema.users.displayName, + avatarUrl: schema.users.avatarUrl, + }, + }) + .from(schema.issueClaims) + .innerJoin(schema.users, eq(schema.issueClaims.userId, schema.users.id)) + .where( + and( + eq(schema.issueClaims.issueId, issueId), + eq(schema.issueClaims.status, "active") + ) + ) + .limit(1); + + if (claims.length === 0) { + return null; + } + + return claims[0] as ActiveIssueClaim; +} + +/** + * Fetches all active claims for a contributor. + */ +export async function getUserActiveClaims(userId: string, database?: any) { + const db = database || (await getDb()); + await expireOverdueClaims(db); + + const claims = await db + .select({ + id: schema.issueClaims.id, + issueId: schema.issueClaims.issueId, + status: schema.issueClaims.status, + claimedAt: schema.issueClaims.claimedAt, + expiresAt: schema.issueClaims.expiresAt, + issueTitle: schema.issues.title, + issueNumber: schema.issues.githubIssueNumber, + issueHtmlUrl: schema.issues.htmlUrl, + difficulty: schema.issues.difficulty, + estimatedEffort: schema.issues.estimatedEffort, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + primaryLanguage: schema.projects.primaryLanguage, + }) + .from(schema.issueClaims) + .innerJoin(schema.issues, eq(schema.issueClaims.issueId, schema.issues.id)) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where( + and( + eq(schema.issueClaims.userId, userId), + eq(schema.issueClaims.status, "active") + ) + ); + + return claims; +} diff --git a/vitest.config.ts b/vitest.config.ts index dadfdd4..5a3e10f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ environment: "node", globals: true, include: ["src/**/*.{test,spec}.{ts,tsx}"], + hookTimeout: 30000, + testTimeout: 30000, coverage: { provider: "v8", reporter: ["text", "json", "html"],