From a883a3ef758446f582e0bbbd13655d1e4dd9d9c5 Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:27:05 +0530 Subject: [PATCH] feat: implement /join, guided 7-step onboarding, and next-action dashboard (Sprint 1) --- drizzle/0001_sprint_lifecycle_tables.sql | 135 +++++ src/app/api/admin/credentials/revoke/route.ts | 13 +- src/app/api/onboarding/route.ts | 92 ++- src/app/dashboard/page.tsx | 436 +++++++++++---- src/app/join/page.tsx | 164 ++++++ src/app/onboarding/OnboardingForm.tsx | 526 +++++++++++++----- src/app/page.tsx | 4 +- src/lib/auth/authorization.ts | 60 ++ src/lib/db/schema.ts | 199 ++++++- 9 files changed, 1375 insertions(+), 254 deletions(-) create mode 100644 drizzle/0001_sprint_lifecycle_tables.sql create mode 100644 src/app/join/page.tsx create mode 100644 src/lib/auth/authorization.ts diff --git a/drizzle/0001_sprint_lifecycle_tables.sql b/drizzle/0001_sprint_lifecycle_tables.sql new file mode 100644 index 0000000..6d50bc9 --- /dev/null +++ b/drizzle/0001_sprint_lifecycle_tables.sql @@ -0,0 +1,135 @@ +-- Add new columns to profiles +ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "technologies" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "tools" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "time_commitment" text;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "primary_goal" text;--> statement-breakpoint + +-- Add new columns to projects for quality gate and trust boundary +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "contribution_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "first_pr_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "approved_at" timestamp with time zone DEFAULT now();--> statement-breakpoint + +-- Create issue_claims table +CREATE TABLE IF NOT EXISTS "issue_claims" ( + "id" text PRIMARY KEY NOT NULL, + "issue_id" text NOT NULL, + "user_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +-- Create pull_requests table +CREATE TABLE IF NOT EXISTS "pull_requests" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "project_id" text NOT NULL, + "issue_id" text, + "github_pr_id" integer NOT NULL, + "github_pr_number" integer NOT NULL, + "title" text NOT NULL, + "url" text NOT NULL, + "state" text DEFAULT 'open' NOT NULL, + "draft" boolean DEFAULT false NOT NULL, + "merge_commit_sha" text, + "opened_at" timestamp with time zone DEFAULT now() NOT NULL, + "merged_at" timestamp with time zone, + "closed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pull_requests_url_unique" UNIQUE("url") +);--> statement-breakpoint + +-- Create pull_request_reviews table +CREATE TABLE IF NOT EXISTS "pull_request_reviews" ( + "id" text PRIMARY KEY NOT NULL, + "pull_request_id" text NOT NULL, + "reviewer_github_id" integer NOT NULL, + "reviewer_username" text NOT NULL, + "review_state" text NOT NULL, + "submitted_at" timestamp with time zone DEFAULT now() NOT NULL, + "github_review_id" integer, + "html_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +-- Create github_webhook_deliveries table +CREATE TABLE IF NOT EXISTS "github_webhook_deliveries" ( + "id" text PRIMARY KEY NOT NULL, + "delivery_id" text NOT NULL, + "event_type" text NOT NULL, + "repository" text NOT NULL, + "received_at" timestamp with time zone DEFAULT now() NOT NULL, + "processed_at" timestamp with time zone, + "status" text DEFAULT 'received' NOT NULL, + "error" text, + CONSTRAINT "webhook_deliveries_delivery_id_unique" UNIQUE("delivery_id") +);--> statement-breakpoint + +-- Create notifications table +CREATE TABLE IF NOT EXISTS "notifications" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "link_url" text, + "is_read" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +-- Create mentor_requests table +CREATE TABLE IF NOT EXISTS "mentor_requests" ( + "id" text PRIMARY KEY NOT NULL, + "student_id" text NOT NULL, + "mentor_id" text, + "issue_id" text NOT NULL, + "message" text NOT NULL, + "reply" text, + "status" text DEFAULT 'open' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone +);--> statement-breakpoint + +-- Add Foreign Keys +ALTER TABLE "issue_claims" ADD CONSTRAINT "issue_claims_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_claims" ADD CONSTRAINT "issue_claims_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint + +ALTER TABLE "pull_request_reviews" ADD CONSTRAINT "pr_reviews_pull_request_id_pull_requests_id_fk" FOREIGN KEY ("pull_request_id") REFERENCES "public"."pull_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_student_id_users_id_fk" FOREIGN KEY ("student_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_mentor_id_users_id_fk" FOREIGN KEY ("mentor_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +-- Indexes +CREATE INDEX IF NOT EXISTS "issue_claims_issue_id_idx" ON "issue_claims" USING btree ("issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_claims_user_id_idx" ON "issue_claims" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_claims_status_idx" ON "issue_claims" USING btree ("status");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "pull_requests_user_id_idx" ON "pull_requests" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pull_requests_project_id_idx" ON "pull_requests" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pull_requests_issue_id_idx" ON "pull_requests" USING btree ("issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pull_requests_state_idx" ON "pull_requests" USING btree ("state");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "pr_reviews_pull_request_id_idx" ON "pull_request_reviews" USING btree ("pull_request_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pr_reviews_reviewer_username_idx" ON "pull_request_reviews" USING btree ("reviewer_username");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "pr_reviews_review_state_idx" ON "pull_request_reviews" USING btree ("review_state");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "webhook_deliveries_status_idx" ON "github_webhook_deliveries" USING btree ("status");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_is_read_idx" ON "notifications" USING btree ("is_read");--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "mentor_requests_student_id_idx" ON "mentor_requests" USING btree ("student_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "mentor_requests_mentor_id_idx" ON "mentor_requests" USING btree ("mentor_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "mentor_requests_issue_id_idx" ON "mentor_requests" USING btree ("issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "mentor_requests_status_idx" ON "mentor_requests" USING btree ("status"); diff --git a/src/app/api/admin/credentials/revoke/route.ts b/src/app/api/admin/credentials/revoke/route.ts index 1c4d716..af04719 100644 --- a/src/app/api/admin/credentials/revoke/route.ts +++ b/src/app/api/admin/credentials/revoke/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { getFreshAuthenticatedUser } from "@/lib/auth/authorization"; import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; @@ -10,13 +10,12 @@ const revokeSchema = z.object({ }); export async function POST(request: NextRequest) { - const token = request.cookies.get(COOKIE_NAME)?.value; - if (!token) { + const freshUser = await getFreshAuthenticatedUser(request); + if (!freshUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const sessionUser = await verifySessionToken(token); - if (!sessionUser || sessionUser.role !== "admin") { + if (freshUser.role !== "admin") { return NextResponse.json( { error: "Forbidden. Administrative privileges required." }, { status: 403 } @@ -54,12 +53,12 @@ export async function POST(request: NextRequest) { // Create immutable audit log await db.insert(schema.auditLogs).values({ id: `audit_${crypto.randomUUID()}`, - actorId: sessionUser.id, + actorId: freshUser.id, action: "credential.revoked", targetType: "credential", targetId: data.credentialId, metadata: { - revokedBy: sessionUser.githubUsername, + revokedBy: freshUser.githubUsername, reason: data.reason, targetUserId: cred.userId, credentialType: cred.type, diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index eb99731..5abfe9d 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -3,13 +3,17 @@ import { z } from "zod"; import { verifySessionToken, createSessionToken, COOKIE_NAME, SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session"; import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; +import { recommendIssues } from "@/lib/recommendations/engine"; const onboardingSchema = z.object({ - skills: z.array(z.string()).min(1, "Select at least one skill"), + experienceLevel: z.enum(["complete_beginner", "beginner", "intermediate", "advanced"]), + preferredLanguages: z.array(z.string()).min(1, "Select at least one programming language"), + technologies: z.array(z.string()).default([]), + tools: z.array(z.string()).default([]), interests: z.array(z.string()).min(1, "Select at least one area of interest"), - experienceLevel: z.enum(["beginner", "intermediate", "advanced"]), - preferredLanguages: z.array(z.string()).default([]), - contributionPreferences: z.array(z.string()).default([]), + contributionPreferences: z.array(z.string()).min(1, "Select at least one contribution preference"), + timeCommitment: z.string().optional(), + primaryGoal: z.string().optional(), }); export async function POST(request: NextRequest) { @@ -29,6 +33,15 @@ export async function POST(request: NextRequest) { const db = await getDb(); + // Deduplicate and combine into backward-compatible skills array + const combinedSkills = Array.from( + new Set([ + ...validated.preferredLanguages, + ...validated.technologies, + ...validated.tools, + ]) + ); + // Upsert profile const existingProfile = await db .select() @@ -40,11 +53,15 @@ export async function POST(request: NextRequest) { await db .update(schema.profiles) .set({ - skills: validated.skills, - interests: validated.interests, experienceLevel: validated.experienceLevel, preferredLanguages: validated.preferredLanguages, + technologies: validated.technologies, + tools: validated.tools, + skills: combinedSkills, + interests: validated.interests, contributionPreferences: validated.contributionPreferences, + timeCommitment: validated.timeCommitment || null, + primaryGoal: validated.primaryGoal || null, updatedAt: new Date(), }) .where(eq(schema.profiles.userId, sessionUser.id)); @@ -52,11 +69,15 @@ export async function POST(request: NextRequest) { await db.insert(schema.profiles).values({ id: `prof_${crypto.randomUUID()}`, userId: sessionUser.id, - skills: validated.skills, - interests: validated.interests, experienceLevel: validated.experienceLevel, preferredLanguages: validated.preferredLanguages, + technologies: validated.technologies, + tools: validated.tools, + skills: combinedSkills, + interests: validated.interests, contributionPreferences: validated.contributionPreferences, + timeCommitment: validated.timeCommitment || null, + primaryGoal: validated.primaryGoal || null, }); } @@ -69,6 +90,49 @@ export async function POST(request: NextRequest) { }) .where(eq(schema.users.id, sessionUser.id)); + // Calculate count of matching issues + const openIssues = await db + .select({ + id: schema.issues.id, + projectId: schema.issues.projectId, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + title: schema.issues.title, + bodySnippet: schema.issues.bodySnippet, + 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, + primaryLanguage: schema.projects.primaryLanguage, + }) + .from(schema.issues) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where(eq(schema.issues.state, "open")) + .limit(50); + + const mappedExpLevel = + validated.experienceLevel === "complete_beginner" + ? "beginner" + : (validated.experienceLevel as "beginner" | "intermediate" | "advanced"); + + const matched = recommendIssues( + openIssues.map((i: any) => ({ + ...i, + difficulty: i.difficulty as "beginner" | "intermediate" | "advanced", + })), + { + skills: combinedSkills, + interests: validated.interests, + experienceLevel: mappedExpLevel, + preferredLanguages: validated.preferredLanguages, + contributionPreferences: validated.contributionPreferences, + }, + 10 + ); + // Refresh session token with isOnboarded = true const updatedSessionUser = { ...sessionUser, @@ -76,7 +140,12 @@ export async function POST(request: NextRequest) { }; const newToken = await createSessionToken(updatedSessionUser); - const response = NextResponse.json({ success: true, redirect: "/dashboard" }); + const response = NextResponse.json({ + success: true, + matchingIssuesCount: matched.length, + redirect: "/dashboard", + }); + response.cookies.set(COOKIE_NAME, newToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -90,6 +159,9 @@ export async function POST(request: NextRequest) { if (error instanceof z.ZodError) { return NextResponse.json({ error: error.errors[0].message }, { status: 400 }); } - return NextResponse.json({ error: error.message || "Failed to complete onboarding" }, { status: 500 }); + return NextResponse.json( + { error: error.message || "Failed to complete onboarding" }, + { status: 500 } + ); } } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f99c788..9e9446b 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 } from "drizzle-orm"; +import { eq, and, desc } from "drizzle-orm"; import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; import { recommendIssues } from "@/lib/recommendations/engine"; import { SyncContributionsButton } from "./SyncContributionsButton"; @@ -20,12 +20,12 @@ export default async function DashboardPage() { const token = cookieStore.get(COOKIE_NAME)?.value; if (!token) { - redirect("/api/auth/github"); + redirect("/join"); } const sessionUser = await verifySessionToken(token); if (!sessionUser) { - redirect("/api/auth/github"); + redirect("/join"); } if (!sessionUser.isOnboarded) { @@ -36,15 +36,56 @@ export default async function DashboardPage() { // Fetch live contributions const userContributions = await db - .select() + .select({ + id: schema.contributions.id, + prNumber: schema.contributions.githubPrNumber, + prTitle: schema.contributions.prTitle, + prUrl: schema.contributions.prUrl, + state: schema.contributions.state, + isFirstPr: schema.contributions.isFirstPr, + mergedAt: schema.contributions.mergedAt, + verifiedAt: schema.contributions.verifiedAt, + createdAt: schema.contributions.createdAt, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + projectId: schema.projects.id, + }) .from(schema.contributions) - .where(eq(schema.contributions.userId, sessionUser.id)); + .innerJoin(schema.projects, eq(schema.contributions.projectId, schema.projects.id)) + .where(eq(schema.contributions.userId, sessionUser.id)) + .orderBy(desc(schema.contributions.createdAt)); + + // Fetch active issue claims + const activeClaims = await db + .select({ + claimId: schema.issueClaims.id, + status: schema.issueClaims.status, + claimedAt: schema.issueClaims.claimedAt, + expiresAt: schema.issueClaims.expiresAt, + issueId: schema.issues.id, + issueTitle: schema.issues.title, + issueNumber: schema.issues.githubIssueNumber, + difficulty: schema.issues.difficulty, + estimatedEffort: schema.issues.estimatedEffort, + githubRepo: schema.projects.githubRepo, + htmlUrl: schema.issues.htmlUrl, + }) + .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, sessionUser.id), + eq(schema.issueClaims.status, "active") + ) + ); // Fetch live credentials const userCredentials = await db .select() .from(schema.credentials) - .where(eq(schema.credentials.userId, sessionUser.id)); + .where(eq(schema.credentials.userId, sessionUser.id)) + .orderBy(desc(schema.credentials.issuedAt)); // Fetch user profile details const userProfiles = await db @@ -88,6 +129,11 @@ export default async function DashboardPage() { .where(eq(schema.issues.state, "open")) .limit(50); + const mappedExpLevel = + profile?.experienceLevel === "complete_beginner" + ? "beginner" + : ((profile?.experienceLevel as any) || "beginner"); + const recommended = profile ? recommendIssues( openIssues.map((i: any) => ({ @@ -97,16 +143,19 @@ export default async function DashboardPage() { { skills: profile.skills || [], interests: profile.interests || [], - experienceLevel: (profile.experienceLevel as any) || "beginner", + experienceLevel: mappedExpLevel, preferredLanguages: profile.preferredLanguages || [], contributionPreferences: profile.contributionPreferences || [], }, - 4 + 5 ) : []; + const topRecommendation = recommended[0]; + const remainingRecommendations = recommended.slice(1); + return ( -
+
{/* Contributor Header */}
@@ -162,116 +211,227 @@ export default async function DashboardPage() {
- {/* Progression & Requirements */} -
-
+ {/* 1. YOUR NEXT CONTRIBUTION (Top Action Hero) */} +
+
-

- Contributor Progression Pipeline +
+ Primary Objective +
+

+ Your Next Contribution

-

- Verified proof of work drives automatic rank elevation. No fake counts or self-awarded titles. -

+ + Browse All Issues Marketplace → +
- -
+ {topRecommendation ? ( +
+
+
+
+ {topRecommendation.issue.githubRepo} + • + {topRecommendation.issue.difficulty} + {topRecommendation.issue.estimatedEffort && ( + <> + • + {topRecommendation.issue.estimatedEffort} + + )} +
- {/* Stats Cards */} -
-
-
Total PRs Merged
-
{mergedCount}
-
Official TechNexus repositories
-
-
-
Verified Credentials
-
- {userCredentials.length} +

+ + {topRecommendation.issue.title} + +

+ + {topRecommendation.issue.bodySnippet && ( +

+ {topRecommendation.issue.bodySnippet} +

+ )} +
+ + {/* Skills Tags */} + {topRecommendation.issue.skillsRequired.length > 0 && ( +
+ {topRecommendation.issue.skillsRequired.map((skill: string) => ( + + {skill} + + ))} +
+ )} +
+ + {/* Why Recommended & CTA */} +
+
+

+ Why this is recommended: +

+
+ {topRecommendation.matchReasons.map((reason: string, i: number) => ( +
+ ✓ + {reason} +
+ ))} +
+
+ +
+ + Start Contribution + → + +
+
-
Cryptographically verifiable records
-
-
-
Experience Profile
-
- {profile?.experienceLevel || "Beginner"} + ) : ( +
+

+ No matching open issues found in official repositories right now. +

+

+ Check back shortly or browse all active issues across official TechNexusOrg repositories. +

+ + Explore Issues Marketplace → +
-
- {profile?.skills?.length || 0} registered skills + )} +
+ + {/* 2. ACTIVE WORK (Claimed Issues Workspace) */} + {activeClaims.length > 0 && ( +
+
+
+ +

+ Active Contribution Workspaces ({activeClaims.length}) +

+
+ In Progress +
+ +
+ {activeClaims.map((claim: any) => ( +
+
+ {claim.githubRepo} + + Claimed Issue #{claim.issueNumber} + +
+ +

+ + {claim.issueTitle} + +

+ +
+ + Expires: {new Date(claim.expiresAt).toLocaleDateString()} + + + Open Workspace → + +
+
+ ))}
-
+ )} - {/* Recommended Issues Section */} -
-
+ {/* 3. CONTRIBUTION PROGRESS (Milestones & Requirements) */} +
+

- Recommended Contributions For You + Contribution Progress & Pipeline

- Deterministic matching based on your skills, experience tier, and interests. + Verified proof of work drives automatic rank elevation. No fake counts or self-awarded titles.

- - Explore All Issues → -
- {recommended.length === 0 ? ( -
-

- No matching issues currently open. -

-

- Browse the contribution marketplace or check official TechNexusOrg repositories on GitHub. -

+ +
+ + {/* 4. ADDITIONAL RECOMMENDED ISSUES */} + {remainingRecommendations.length > 0 && ( +
+
+
+

+ More Recommended Issues +

+

+ Matched to your languages, skills, and contribution preferences. +

+
- Browse Issues Marketplace → + View All Issues →
- ) : ( +
- {recommended.map(({ issue, score, matchReasons }) => ( + {remainingRecommendations.map(({ issue, matchReasons }) => (
-
- - {issue.githubRepo} - - - Match Score: {score} - +
+ {issue.githubRepo} + {issue.difficulty}

- {issue.title} - +

{matchReasons.length > 0 && (
- {matchReasons.map((reason, idx) => ( + {matchReasons.slice(0, 2).map((reason: string, idx: number) => (
- Difficulty: {issue.difficulty} + {issue.estimatedEffort || "1–3 hours"} + + View Details → + +
+
+ ))} +
+
+ )} + + {/* 5. CONTRIBUTION HISTORY */} +
+
+

+ Contribution History ({userContributions.length}) +

+ + {mergedCount} merged on official projects + +
+ + {userContributions.length === 0 ? ( +
+

+ No contributions recorded yet. +

+

+ Claim an issue and submit your pull request on GitHub to see live contribution tracking. +

+
+ ) : ( +
+ {userContributions.map((contrib: any) => ( +
+
+
+ + {contrib.state} + + {contrib.githubRepo} +
+

+ + PR #{contrib.prNumber}: {contrib.prTitle} + +

+
+ +
+
+ {contrib.mergedAt + ? `Merged ${new Date(contrib.mergedAt).toLocaleDateString()}` + : `Opened ${new Date(contrib.createdAt).toLocaleDateString()}`} +
- View on GitHub → + View on GitHub ↗
@@ -303,14 +534,14 @@ export default async function DashboardPage() { )}
- {/* Verified Credentials Section */} + {/* 6. VERIFIED CREDENTIALS */}

- Verified Credentials + Verified Credentials ({userCredentials.length})

- {userCredentials.length} issued + Cryptographic proof of work records
@@ -322,12 +553,6 @@ export default async function DashboardPage() {

Merge your first pull request on an official project to earn the verified #FirstPR credential.

- - Start #FirstPR Program → -
) : (
@@ -344,7 +569,7 @@ export default async function DashboardPage() { {new Date(cred.issuedAt).toLocaleDateString()}
-

{cred.title}

+

{cred.title}

{cred.description}

)}
+ + {/* 7. SUPPORTING STATS */} +
+
+
Total PRs Merged
+
{mergedCount}
+
Official TechNexus repositories
+
+
+
Verified Credentials
+
+ {userCredentials.length} +
+
Verifiable records
+
+
+
Experience Profile
+
+ {profile?.experienceLevel?.replace("_", " ") || "Beginner"} +
+
+ {profile?.skills?.length || 0} registered skills +
+
+
); } diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx new file mode 100644 index 0000000..7921b4c --- /dev/null +++ b/src/app/join/page.tsx @@ -0,0 +1,164 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { getLivePlatformMetrics } from "@/lib/metrics"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Join TechNexusOrg — Build Real Software, Build Real Proof", + description: + "Join TechNexusOrg with GitHub. Work on real open-source repositories, earn verified proof of work, and build a public engineering track record.", +}; + +export default async function JoinPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (token) { + const session = await verifySessionToken(token); + if (session) { + if (!session.isOnboarded) { + redirect("/onboarding"); + } else { + redirect("/dashboard"); + } + } + } + + const metrics = await getLivePlatformMetrics(); + + return ( +
+ {/* Hero Section */} +
+
+ GitHub-Native Open Source Infrastructure +
+ +

+ Build Real Software. +
+ + Build Real Proof. + +

+ +

+ TechNexusOrg helps students and engineers move from learning syntax to verified open-source engineering experience. +

+ + {/* Primary CTA */} +
+ + Continue with GitHub + → + + + Explore Projects + +
+ +

+ OAuth requires read access to your public GitHub profile and verified email. We never access private code. +

+
+ + {/* Value Pillars Checklist */} +
+

+ What Happens When You Join TechNexusOrg +

+ +
+
+ ✓ +
+
Real Open-Source Projects
+
+ Work on active, curated repositories with real production architecture and tests. +
+
+
+ +
+ ✓ +
+
Real GitHub Issues
+
+ Claim genuine issues matched deterministically to your current skills and experience. +
+
+
+ +
+ ✓ +
+
Real Pull Requests
+
+ Write code, submit standard Git pull requests, and resolve code review feedback. +
+
+
+ +
+ ✓ +
+
Maintainer Review
+
+ Receive rigorous code review from experienced project maintainers and mentors. +
+
+
+ +
+ ✓ +
+
Public Contributor Passport
+
+ Your public proof-of-work portfolio at /people/[username] backed by GitHub evidence. +
+
+
+ +
+ ✓ +
+
Verifiable Credentials
+
+ Permanent records with direct GitHub links and embeddable badges for your README. +
+
+
+
+
+ + {/* Live Community Proof */} +
+
+
{metrics.totalContributors}
+
Contributors
+
+
+
{metrics.totalMergedPrs}
+
PRs Merged
+
+
+
{metrics.totalOfficialProjects}
+
Official Projects
+
+
+
{metrics.foundingMembersCount} / 1,000
+
Founding Cohort
+
+
+
+ ); +} diff --git a/src/app/onboarding/OnboardingForm.tsx b/src/app/onboarding/OnboardingForm.tsx index d318b32..7876f7a 100644 --- a/src/app/onboarding/OnboardingForm.tsx +++ b/src/app/onboarding/OnboardingForm.tsx @@ -2,49 +2,124 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; -const SKILL_OPTIONS = [ +const EXPERIENCE_OPTIONS = [ + { + key: "complete_beginner", + title: "Complete Beginner", + desc: "New to programming or never used Git/GitHub before", + }, + { + key: "beginner", + title: "Beginner", + desc: "Know basic programming; ready to make first open-source PR", + }, + { + key: "intermediate", + title: "Intermediate", + desc: "Comfortable with Git workflows, branches, and code reviews", + }, + { + key: "advanced", + title: "Advanced", + desc: "Experienced engineer, architectural contributor, or maintainer", + }, +] as const; + +const PROGRAMMING_LANGUAGES = [ "TypeScript", "JavaScript", "Python", "Go", "Rust", + "Java", + "C", + "C++", + "Kotlin", + "Swift", + "Ruby", + "PHP", + "SQL", + "Shell / Bash", +]; + +const TECHNOLOGIES_AND_TOOLS = [ + "Git & GitHub", + "Linux / POSIX", + "Docker", + "PostgreSQL", "React", "Next.js", "Node.js", - "PostgreSQL", - "Docker", - "Linux", - "Git & GitHub Actions", + "CI/CD Actions", + "Cloud Infrastructure", + "Tailwind CSS", + "Kubernetes", + "GraphQL & REST APIs", + "Testing (Vitest/Jest)", ]; -const INTEREST_OPTIONS = [ - "Backend & API Systems", +const INTEREST_AREAS = [ + "Backend & Distributed Systems", "Frontend & User Interfaces", - "DevOps & Infrastructure", - "Developer Tooling & CLIs", - "Distributed Systems", - "Security & Code Auditing", + "DevOps & Platform Engineering", + "AI / Machine Learning", + "Cybersecurity & Code Auditing", + "Developer Tools & CLIs", + "Linux & Systems Engineering", + "Documentation & Community Guides", + "Testing & Quality Assurance", + "Mobile & Embedded Applications", ]; -const CONTRIBUTION_TYPES = [ - "Good First Issues & Bug Fixes", - "Documentation & Guides", - "Feature Implementation", +const CONTRIBUTION_PREFERENCES = [ + "Good First Issues", + "Bug Fixes", + "Documentation & Tutorials", "Unit & E2E Testing", - "Code Review & Mentorship", + "Feature Implementation", + "Developer Tooling", + "Code Review & Peer Feedback", + "Mentorship", +]; + +const TIME_COMMITMENTS = [ + { key: "1-2_hours", label: "1–2 hours / week", desc: "Light exploration" }, + { key: "2-4_hours", label: "2–4 hours / week", desc: "Steady progress" }, + { key: "4-8_hours", label: "4–8 hours / week", desc: "Active engineering" }, + { key: "8+_hours", label: "8+ hours / week", desc: "Deep immersion" }, + { key: "flexible", label: "Flexible", desc: "Contribute as time permits" }, +]; + +const GOAL_OPTIONS = [ + { key: "first_pr", label: "Make my first legitimate PR", desc: "Earn verified proof of work" }, + { key: "open_source_experience", label: "Gain open-source experience", desc: "Learn production conventions" }, + { key: "portfolio", label: "Build my public portfolio", desc: "Verifiable Contributor Passport" }, + { key: "learn_from_maintainers", label: "Learn from maintainers", desc: "Receive technical code reviews" }, + { key: "become_maintainer", label: "Grow into a project maintainer", desc: "Steward open-source projects" }, ]; export function OnboardingForm({ user }: { user: { githubUsername: string; displayName: string | null } }) { const router = useRouter(); - const [skills, setSkills] = useState([]); - const [interests, setInterests] = useState([]); - const [experienceLevel, setExperienceLevel] = useState<"beginner" | "intermediate" | "advanced">("beginner"); - const [contributionPreferences, setContributionPreferences] = useState([]); + const [step, setStep] = useState(1); + const totalSteps = 7; + + // Form State + const [experienceLevel, setExperienceLevel] = useState<"complete_beginner" | "beginner" | "intermediate" | "advanced">("beginner"); + const [preferredLanguages, setPreferredLanguages] = useState(["TypeScript", "Python"]); + const [technologies, setTechnologies] = useState(["Git & GitHub", "React"]); + const tools = technologies.filter((t) => ["Git & GitHub", "Docker", "Linux / POSIX", "CI/CD Actions"].includes(t)); + const [interests, setInterests] = useState(["Backend & Distributed Systems"]); + const [contributionPreferences, setContributionPreferences] = useState(["Good First Issues"]); + const [timeCommitment, setTimeCommitment] = useState("2-4_hours"); + const [primaryGoal, setPrimaryGoal] = useState("first_pr"); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [completionData, setCompletionData] = useState<{ matchingIssuesCount: number } | null>(null); - const toggleItem = (list: string[], setList: (v: string[]) => void, item: string) => { + const toggleArrayItem = (list: string[], setList: (v: string[]) => void, item: string) => { if (list.includes(item)) { setList(list.filter((i) => i !== item)); } else { @@ -52,17 +127,29 @@ export function OnboardingForm({ user }: { user: { githubUsername: string; displ } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (skills.length === 0) { - setError("Please select at least one skill."); + const handleNext = () => { + setError(null); + if (step === 2 && preferredLanguages.length === 0) { + setError("Please select at least one programming language."); return; } - if (interests.length === 0) { + if (step === 4 && interests.length === 0) { setError("Please select at least one area of interest."); return; } + if (step === 5 && contributionPreferences.length === 0) { + setError("Please select at least one contribution type."); + return; + } + setStep((s) => Math.min(s + 1, totalSteps)); + }; + const handleBack = () => { + setError(null); + setStep((s) => Math.max(s - 1, 1)); + }; + + const handleSubmit = async () => { setSubmitting(true); setError(null); @@ -71,11 +158,14 @@ export function OnboardingForm({ user }: { user: { githubUsername: string; displ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - skills, - interests, experienceLevel, - preferredLanguages: skills, + preferredLanguages, + technologies, + tools, + interests, contributionPreferences, + timeCommitment, + primaryGoal, }), }); @@ -84,7 +174,7 @@ export function OnboardingForm({ user }: { user: { githubUsername: string; displ throw new Error(data.error || "Failed to complete onboarding"); } - router.push(data.redirect || "/dashboard"); + setCompletionData({ matchingIssuesCount: data.matchingIssuesCount ?? 0 }); router.refresh(); } catch (err: any) { setError(err.message); @@ -92,141 +182,295 @@ export function OnboardingForm({ user }: { user: { githubUsername: string; displ } }; + // Completion Screen + if (completionData) { + return ( +
+
+ ✓ +
+ +
+
+ Onboarding Completed +
+

+ Your First Contribution is Ready +

+

+ Welcome to the community, @{user.githubUsername}. We + analyzed your skills and identified{" "} + {completionData.matchingIssuesCount} open + issues matching your profile. +

+
+ +
+ + View Recommended Issues + → + + + Go to Contributor Dashboard + +
+
+ ); + } + return ( -
-
-

- Contributor Profile Onboarding -

-

- Welcome @{user.githubUsername}. Configure your skills and interests to receive deterministic issue recommendations. -

+
+ {/* Header & Step Indicator */} +
+
+ STEP {step} OF {totalSteps} + {Math.round((step / totalSteps) * 100)}% COMPLETED +
+ + {/* Progress Bar */} +
+
+
+ +
+

+ {step === 1 && "Step 1: What is your current engineering experience?"} + {step === 2 && "Step 2: Select your primary programming languages"} + {step === 3 && "Step 3: Which technologies & tools do you work with?"} + {step === 4 && "Step 4: What technical domains interest you most?"} + {step === 5 && "Step 5: How would you like to contribute?"} + {step === 6 && "Step 6: Estimated weekly time commitment"} + {step === 7 && "Step 7: What is your primary open-source goal?"} +

+

+ {step === 1 && "This helps us calibrate issue difficulty and beginner-friendly mentorship."} + {step === 2 && "We match issues where the repository code is written in these languages."} + {step === 3 && "Tools, frameworks, and deployment platforms you want to learn or use."} + {step === 4 && "Choose architectural domains for curated issue discovery."} + {step === 5 && "From good-first-issues to tests, docs, or feature implementation."} + {step === 6 && "Helps us recommend issues with the appropriate effort estimate."} + {step === 7 && "Your journey: first PR, portfolio building, or maintainer roadmap."} +

+
{error && ( -
+
{error}
)} - {/* Experience Level */} -
- -
- {(["beginner", "intermediate", "advanced"] as const).map((level) => ( + {/* Step 1: Experience */} + {step === 1 && ( +
+ {EXPERIENCE_OPTIONS.map((opt) => ( ))}
-
+ )} - {/* Skills */} -
- -
- {SKILL_OPTIONS.map((skill) => { - const selected = skills.includes(skill); - return ( - - ); - })} + {/* Step 2: Languages */} + {step === 2 && ( +
+
+ {PROGRAMMING_LANGUAGES.map((lang) => { + const selected = preferredLanguages.includes(lang); + return ( + + ); + })} +
-
+ )} - {/* Interests */} -
- -
- {INTEREST_OPTIONS.map((interest) => { - const selected = interests.includes(interest); - return ( - - ); - })} + {/* Step 3: Technologies & Tools */} + {step === 3 && ( +
+
+ {TECHNOLOGIES_AND_TOOLS.map((tech) => { + const selected = technologies.includes(tech); + return ( + + ); + })} +
-
+ )} - {/* Contribution Preferences */} -
- -
- {CONTRIBUTION_TYPES.map((type) => { - const selected = contributionPreferences.includes(type); - return ( - - ); - })} + {/* Step 4: Interests */} + {step === 4 && ( +
+
+ {INTEREST_AREAS.map((interest) => { + const selected = interests.includes(interest); + return ( + + ); + })} +
-
+ )} + + {/* Step 5: Contribution Preference */} + {step === 5 && ( +
+
+ {CONTRIBUTION_PREFERENCES.map((pref) => { + const selected = contributionPreferences.includes(pref); + return ( + + ); + })} +
+
+ )} + + {/* Step 6: Time Commitment */} + {step === 6 && ( +
+ {TIME_COMMITMENTS.map((item) => ( + + ))} +
+ )} + {/* Step 7: Goal */} + {step === 7 && ( +
+ {GOAL_OPTIONS.map((goal) => ( + + ))} +
+ )} + + {/* Stepper Navigation Buttons */}
- - Can be updated anytime from your settings. - - + {step > 1 ? ( + + ) : ( +
+ )} + + {step < totalSteps ? ( + + ) : ( + + )}
- +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index f30a18a..d5205b5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -33,10 +33,10 @@ export default async function HomePage() {
- Join Founding 1,000 + Join with GitHub { value: string } | undefined } } +): Promise { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return null; + } + + const session = await verifySessionToken(token); + if (!session || !session.id) { + return null; + } + + const db = await getDb(); + const users = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, session.id)) + .limit(1); + + if (users.length === 0) { + return null; + } + + return users[0] as AuthenticatedUserRecord; +} + +/** + * Checks whether the fresh authenticated user has one of the allowed roles. + */ +export function hasRole( + user: AuthenticatedUserRecord | null, + allowedRoles: string[] +): boolean { + if (!user) return false; + return allowedRoles.includes(user.role); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index a6c9166..a01221a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -69,7 +69,7 @@ export const profiles = pgTable( skills: jsonb("skills").$type().default([]).notNull(), interests: jsonb("interests").$type().default([]).notNull(), experienceLevel: text("experience_level", { - enum: ["beginner", "intermediate", "advanced"], + enum: ["complete_beginner", "beginner", "intermediate", "advanced"], }) .default("beginner") .notNull(), @@ -77,10 +77,20 @@ export const profiles = pgTable( .$type() .default([]) .notNull(), + technologies: jsonb("technologies") + .$type() + .default([]) + .notNull(), + tools: jsonb("tools") + .$type() + .default([]) + .notNull(), contributionPreferences: jsonb("contribution_preferences") .$type() .default([]) .notNull(), + timeCommitment: text("time_commitment"), + primaryGoal: text("primary_goal"), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), @@ -106,6 +116,9 @@ export const projects = pgTable( .notNull(), maintainerId: text("maintainer_id").references(() => users.id), isOfficial: boolean("is_official").default(true).notNull(), + contributionEnabled: boolean("contribution_enabled").default(true).notNull(), + firstPrEnabled: boolean("first_pr_enabled").default(true).notNull(), + approvedAt: timestamp("approved_at", { withTimezone: true }).defaultNow(), starsCount: integer("stars_count").default(0).notNull(), forksCount: integer("forks_count").default(0).notNull(), openIssuesCount: integer("open_issues_count").default(0).notNull(), @@ -289,3 +302,187 @@ export const auditLogs = pgTable( index("audit_logs_target_idx").on(table.targetType, table.targetId), ] ); + +// --- ISSUE CLAIMS --- +export const issueClaims = pgTable( + "issue_claims", + { + id: text("id").primaryKey(), + issueId: text("issue_id") + .notNull() + .references(() => issues.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + status: text("status", { + enum: ["active", "completed", "released", "expired", "cancelled"], + }) + .default("active") + .notNull(), + claimedAt: timestamp("claimed_at", { withTimezone: true }) + .defaultNow() + .notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + releasedAt: timestamp("released_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("issue_claims_issue_id_idx").on(table.issueId), + index("issue_claims_user_id_idx").on(table.userId), + index("issue_claims_status_idx").on(table.status), + ] +); + +// --- PULL REQUESTS --- +export const pullRequests = pgTable( + "pull_requests", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + issueId: text("issue_id").references(() => issues.id), + githubPrId: integer("github_pr_id").notNull(), + githubPrNumber: integer("github_pr_number").notNull(), + title: text("title").notNull(), + url: text("url").notNull().unique(), + state: text("state", { + enum: ["open", "approved", "changes_requested", "merged", "closed"], + }) + .default("open") + .notNull(), + draft: boolean("draft").default(false).notNull(), + mergeCommitSha: text("merge_commit_sha"), + openedAt: timestamp("opened_at", { withTimezone: true }) + .defaultNow() + .notNull(), + mergedAt: timestamp("merged_at", { withTimezone: true }), + closedAt: timestamp("closed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("pull_requests_url_idx").on(table.url), + index("pull_requests_user_id_idx").on(table.userId), + index("pull_requests_project_id_idx").on(table.projectId), + index("pull_requests_issue_id_idx").on(table.issueId), + index("pull_requests_state_idx").on(table.state), + ] +); + +// --- PULL REQUEST REVIEWS --- +export const pullRequestReviews = pgTable( + "pull_request_reviews", + { + id: text("id").primaryKey(), + pullRequestId: text("pull_request_id") + .notNull() + .references(() => pullRequests.id, { onDelete: "cascade" }), + reviewerGithubId: integer("reviewer_github_id").notNull(), + reviewerUsername: text("reviewer_username").notNull(), + reviewState: text("review_state", { + enum: ["approved", "changes_requested", "commented", "dismissed"], + }).notNull(), + submittedAt: timestamp("submitted_at", { withTimezone: true }) + .defaultNow() + .notNull(), + githubReviewId: integer("github_review_id"), + htmlUrl: text("html_url"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("pr_reviews_pull_request_id_idx").on(table.pullRequestId), + index("pr_reviews_reviewer_username_idx").on(table.reviewerUsername), + index("pr_reviews_review_state_idx").on(table.reviewState), + ] +); + +// --- GITHUB WEBHOOK DELIVERIES (IDEMPOTENCY) --- +export const githubWebhookDeliveries = pgTable( + "github_webhook_deliveries", + { + id: text("id").primaryKey(), + deliveryId: text("delivery_id").notNull().unique(), + eventType: text("event_type").notNull(), + repository: text("repository").notNull(), + receivedAt: timestamp("received_at", { withTimezone: true }) + .defaultNow() + .notNull(), + processedAt: timestamp("processed_at", { withTimezone: true }), + status: text("status", { enum: ["received", "processed", "ignored", "error"] }) + .default("received") + .notNull(), + error: text("error"), + }, + (table) => [ + uniqueIndex("webhook_deliveries_delivery_id_idx").on(table.deliveryId), + index("webhook_deliveries_status_idx").on(table.status), + ] +); + +// --- NOTIFICATIONS --- +export const notifications = pgTable( + "notifications", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + title: text("title").notNull(), + message: text("message").notNull(), + linkUrl: text("link_url"), + isRead: boolean("is_read").default(false).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("notifications_user_id_idx").on(table.userId), + index("notifications_is_read_idx").on(table.isRead), + ] +); + +// --- MENTOR REQUESTS --- +export const mentorRequests = pgTable( + "mentor_requests", + { + id: text("id").primaryKey(), + studentId: text("student_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + mentorId: text("mentor_id").references(() => users.id), + issueId: text("issue_id") + .notNull() + .references(() => issues.id, { onDelete: "cascade" }), + message: text("message").notNull(), + reply: text("reply"), + status: text("status", { enum: ["open", "in_progress", "resolved", "closed"] }) + .default("open") + .notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + }, + (table) => [ + index("mentor_requests_student_id_idx").on(table.studentId), + index("mentor_requests_mentor_id_idx").on(table.mentorId), + index("mentor_requests_issue_id_idx").on(table.issueId), + index("mentor_requests_status_idx").on(table.status), + ] +);