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 ( -
- Verified proof of work drives automatic rank elevation. No fake counts or self-awarded titles. -
+ {topRecommendation.issue.bodySnippet} +
+ )} ++ 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 → +- 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.
- No matching issues currently open. -
-- Browse the contribution marketplace or check official TechNexusOrg repositories on GitHub. -
++ Matched to your languages, skills, and contribution preferences. +
++ No contributions recorded yet. +
++ Claim an issue and submit your pull request on GitHub to see live contribution tracking. +
+Merge your first pull request on an official project to earn the verified #FirstPR credential.
- - Start #FirstPR Program → -{cred.description}
+ TechNexusOrg helps students and engineers move from learning syntax to verified open-source engineering experience. +
+ + {/* Primary CTA */} ++ OAuth requires read access to your public GitHub profile and verified email. We never access private code. +
+/people/[username] backed by GitHub evidence.
+ + Welcome to the community, @{user.githubUsername}. We + analyzed your skills and identified{" "} + {completionData.matchingIssuesCount} open + issues matching your profile. +
+