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.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 ? (
+

+ ) : (
+
+ @
+
+ )}
+
+ 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"],