diff --git a/drizzle/0008_internship_management.sql b/drizzle/0008_internship_management.sql new file mode 100644 index 0000000..94b6034 --- /dev/null +++ b/drizzle/0008_internship_management.sql @@ -0,0 +1,132 @@ +CREATE TABLE IF NOT EXISTS "internship_programs" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "track" text DEFAULT 'systems' NOT NULL, + "description" text NOT NULL, + "duration_weeks" integer DEFAULT 8 NOT NULL, + "is_published" boolean DEFAULT true NOT NULL, + "prerequisites" jsonb DEFAULT '[]'::jsonb NOT NULL, + "learning_outcomes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "internship_programs_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "cohorts" ( + "id" text PRIMARY KEY NOT NULL, + "program_id" text NOT NULL REFERENCES "internship_programs"("id") ON DELETE cascade, + "name" text NOT NULL, + "start_date" timestamp with time zone NOT NULL, + "end_date" timestamp with time zone NOT NULL, + "capacity" integer DEFAULT 25 NOT NULL, + "status" text DEFAULT 'upcoming' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "internship_applications" ( + "id" text PRIMARY KEY NOT NULL, + "program_id" text NOT NULL REFERENCES "internship_programs"("id") ON DELETE cascade, + "cohort_id" text REFERENCES "cohorts"("id") ON DELETE set null, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "statement" text NOT NULL, + "weekly_availability_hours" integer NOT NULL, + "github_profile" text NOT NULL, + "prior_experience" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "reviewed_by" text REFERENCES "users"("id"), + "review_notes" text, + "reviewed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "internship_enrollments" ( + "id" text PRIMARY KEY NOT NULL, + "program_id" text NOT NULL REFERENCES "internship_programs"("id") ON DELETE cascade, + "cohort_id" text REFERENCES "cohorts"("id") ON DELETE set null, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "mentor_id" text REFERENCES "users"("id"), + "offer_letter_id" text, + "offer_hash" text, + "status" text DEFAULT 'active' NOT NULL, + "start_date" timestamp with time zone DEFAULT now() NOT NULL, + "target_end_date" timestamp with time zone NOT NULL, + "completed_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 TABLE IF NOT EXISTS "internship_milestones" ( + "id" text PRIMARY KEY NOT NULL, + "program_id" text NOT NULL REFERENCES "internship_programs"("id") ON DELETE cascade, + "title" text NOT NULL, + "description" text NOT NULL, + "week_number" integer NOT NULL, + "required_deliverables" jsonb DEFAULT '[]'::jsonb NOT NULL, + "order_index" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "internship_work_logs" ( + "id" text PRIMARY KEY NOT NULL, + "enrollment_id" text NOT NULL REFERENCES "internship_enrollments"("id") ON DELETE cascade, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "week_number" integer NOT NULL, + "hours_spent" integer NOT NULL, + "summary" text NOT NULL, + "pull_request_urls" jsonb DEFAULT '[]'::jsonb NOT NULL, + "blockers" text, + "mentor_feedback" text, + "mentor_reviewed_at" timestamp with time zone, + "submitted_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "internship_evaluations" ( + "id" text PRIMARY KEY NOT NULL, + "enrollment_id" text NOT NULL REFERENCES "internship_enrollments"("id") ON DELETE cascade, + "mentor_id" text NOT NULL REFERENCES "users"("id"), + "code_quality_score" integer NOT NULL, + "collaboration_score" integer NOT NULL, + "punctuality_score" integer NOT NULL, + "documentation_score" integer NOT NULL, + "technical_initiative_score" integer NOT NULL, + "overall_feedback" text NOT NULL, + "recommend_completion" boolean DEFAULT true NOT NULL, + "evaluated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "internship_programs_slug_idx" ON "internship_programs" ("slug"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_programs_is_published_idx" ON "internship_programs" ("is_published"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cohorts_program_id_idx" ON "cohorts" ("program_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cohorts_status_idx" ON "cohorts" ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_applications_user_idx" ON "internship_applications" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_applications_program_idx" ON "internship_applications" ("program_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_applications_status_idx" ON "internship_applications" ("status"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "internship_enrollments_user_prog_idx" ON "internship_enrollments" ("user_id", "program_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_enrollments_user_idx" ON "internship_enrollments" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_enrollments_mentor_idx" ON "internship_enrollments" ("mentor_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_enrollments_status_idx" ON "internship_enrollments" ("status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_milestones_program_idx" ON "internship_milestones" ("program_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_milestones_week_idx" ON "internship_milestones" ("program_id", "week_number"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_work_logs_enrollment_idx" ON "internship_work_logs" ("enrollment_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_work_logs_user_idx" ON "internship_work_logs" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_work_logs_week_idx" ON "internship_work_logs" ("enrollment_id", "week_number"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "internship_evaluations_enrollment_idx" ON "internship_evaluations" ("enrollment_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "internship_evaluations_mentor_idx" ON "internship_evaluations" ("mentor_id"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 18c99d9..cb408d5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1793327000000, "tag": "0007_learning_platform", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1793328000000, + "tag": "0008_internship_management", + "breakpoints": true } ] } diff --git a/src/app/api/internships/apply/route.ts b/src/app/api/internships/apply/route.ts new file mode 100644 index 0000000..4d324ef --- /dev/null +++ b/src/app/api/internships/apply/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { applyForInternship } from "@/lib/internships/service"; + +const applicationSchema = z.object({ + programId: z.string().min(1, "Program ID is required"), + cohortId: z.string().optional(), + statement: z + .string() + .min(50, "Statement of purpose must be at least 50 characters"), + weeklyAvailabilityHours: z + .number() + .min(10, "Minimum 10 hours per week required") + .max(60), + githubProfile: z.string().url("Valid GitHub profile URL required"), + priorExperience: z.string().min(1, "Prior experience description required"), +}); + +export async function POST(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const body = await request.json(); + const data = applicationSchema.parse(body); + + const application = await applyForInternship(sessionUser.id, data); + return NextResponse.json({ success: true, application }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid application data", details: error.flatten() }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message || "Failed to submit application" }, + { status: 400 } + ); + } +} diff --git a/src/app/api/internships/portal/route.ts b/src/app/api/internships/portal/route.ts new file mode 100644 index 0000000..cc231d9 --- /dev/null +++ b/src/app/api/internships/portal/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { getInternshipPortalData } from "@/lib/internships/service"; + +export async function GET(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const portalData = await getInternshipPortalData(sessionUser.id); + return NextResponse.json({ portalData }); + } catch (error: any) { + console.error("Failed to load internship portal data:", error); + return NextResponse.json( + { error: "Failed to load portal data", details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/internships/programs/route.ts b/src/app/api/internships/programs/route.ts new file mode 100644 index 0000000..36817c7 --- /dev/null +++ b/src/app/api/internships/programs/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { listInternshipPrograms } from "@/lib/internships/service"; + +export async function GET() { + try { + const programs = await listInternshipPrograms(); + return NextResponse.json({ programs }); + } catch (error: any) { + console.error("Failed to list internship programs:", error); + return NextResponse.json( + { error: "Failed to list internship programs", details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/internships/routes.test.ts b/src/app/api/internships/routes.test.ts new file mode 100644 index 0000000..61d0e05 --- /dev/null +++ b/src/app/api/internships/routes.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { NextRequest } from "next/server"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { createSessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { GET as getProgramsHandler } from "./programs/route"; +import { POST as applyHandler } from "./apply/route"; +import { GET as getPortalHandler } from "./portal/route"; +import { POST as submitWorkLogHandler } from "./work-logs/route"; +import { listInternshipPrograms, reviewApplication } from "@/lib/internships/service"; + +describe("Internship API Routes", () => { + let db: any; + const testStudentId = `usr_api_intern_${Date.now()}`; + const testMentorId = `usr_api_mentor_${Date.now()}`; + let studentCookie: string; + let enrollmentId: string; + + beforeAll(async () => { + db = await getDb(); + await runMigrations(); + + await db.insert(schema.users).values([ + { + id: testStudentId, + githubId: Math.floor(Math.random() * 1000000), + githubUsername: "api_intern", + email: "intern@technexus.org", + role: "contributor", + }, + { + id: testMentorId, + githubId: Math.floor(Math.random() * 1000000), + githubUsername: "api_mentor", + email: "mentor@technexus.org", + role: "maintainer", + }, + ]); + + const token = await createSessionToken({ + id: testStudentId, + githubId: 123456, + githubUsername: "api_intern", + displayName: "API Intern", + email: "intern@technexus.org", + avatarUrl: null, + role: "contributor", + level: "builder", + isOnboarded: true, + foundingNumber: null, + }); + + studentCookie = `${COOKIE_NAME}=${token}`; + }); + + it("GET /api/internships/programs returns published programs", async () => { + const res = await getProgramsHandler(); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.programs).toBeDefined(); + expect(data.programs.length).toBeGreaterThanOrEqual(2); + }); + + it("POST /api/internships/apply submits an application", async () => { + const programs = await listInternshipPrograms(db); + const prog = programs[0]; + + // Unauthorized request + const unauthReq = new NextRequest("http://localhost:3000/api/internships/apply", { + method: "POST", + body: JSON.stringify({ programId: prog.id }), + }); + const unauthRes = await applyHandler(unauthReq); + expect(unauthRes.status).toBe(401); + + // Valid application + const authReq = new NextRequest("http://localhost:3000/api/internships/apply", { + method: "POST", + headers: { + cookie: studentCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ + programId: prog.id, + statement: + "I am excited to build real backend features for TechNexusOrg and participate in code reviews.", + weeklyAvailabilityHours: 15, + githubProfile: "https://github.com/api_intern", + priorExperience: "Strong experience with TypeScript, Next.js, and SQL.", + }), + }); + + const res = await applyHandler(authReq); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.application.status).toBe("pending"); + + // Review and accept application to create active enrollment + const reviewResult = await reviewApplication( + testMentorId, + data.application.id, + { + status: "accepted", + mentorId: testMentorId, + }, + db + ); + enrollmentId = reviewResult.enrollment!.id; + }); + + it("GET /api/internships/portal returns active enrollment details", async () => { + const req = new NextRequest("http://localhost:3000/api/internships/portal", { + headers: { cookie: studentCookie }, + }); + + const res = await getPortalHandler(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.portalData).toBeDefined(); + expect(data.portalData.enrollment.id).toBe(enrollmentId); + expect(data.portalData.program).toBeDefined(); + }); + + it("POST /api/internships/work-logs submits weekly work logs", async () => { + const req = new NextRequest("http://localhost:3000/api/internships/work-logs", { + method: "POST", + headers: { + cookie: studentCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ + enrollmentId, + weekNumber: 1, + hoursSpent: 15, + summary: "Created unit tests and updated API route schema for issues.", + pullRequestUrls: ["https://github.com/TechNexusOrg/platform/pull/16"], + blockers: "None", + }), + }); + + const res = await submitWorkLogHandler(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.workLog.weekNumber).toBe(1); + expect(data.workLog.hoursSpent).toBe(15); + }); +}); diff --git a/src/app/api/internships/work-logs/route.ts b/src/app/api/internships/work-logs/route.ts new file mode 100644 index 0000000..bd7a15c --- /dev/null +++ b/src/app/api/internships/work-logs/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { submitWorkLog } from "@/lib/internships/service"; + +const workLogSchema = z.object({ + enrollmentId: z.string().min(1, "Enrollment ID is required"), + weekNumber: z.number().int().min(1).max(20), + hoursSpent: z.number().int().min(1).max(60), + summary: z.string().min(20, "Summary must be at least 20 characters"), + pullRequestUrls: z.array(z.string().url()).min(1, "At least 1 PR URL is required"), + blockers: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const body = await request.json(); + const data = workLogSchema.parse(body); + + const log = await submitWorkLog(sessionUser.id, data.enrollmentId, { + weekNumber: data.weekNumber, + hoursSpent: data.hoursSpent, + summary: data.summary, + pullRequestUrls: data.pullRequestUrls, + blockers: data.blockers, + }); + + return NextResponse.json({ success: true, workLog: log }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid work log data", details: error.flatten() }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message || "Failed to submit work log" }, + { status: 400 } + ); + } +} diff --git a/src/app/internships/ApplyModal.tsx b/src/app/internships/ApplyModal.tsx new file mode 100644 index 0000000..e4ca1e2 --- /dev/null +++ b/src/app/internships/ApplyModal.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React, { useState } from "react"; + +interface ApplyModalProps { + programs: Array<{ id: string; title: string; slug: string; durationWeeks: number }>; + isLoggedIn: boolean; +} + +export function ApplyModal({ programs, isLoggedIn }: ApplyModalProps) { + const [isOpen, setIsOpen] = useState(false); + const [programId, setProgramId] = useState(programs[0]?.id || ""); + const [statement, setStatement] = useState(""); + const [weeklyHours, setWeeklyHours] = useState(15); + const [githubProfile, setGithubProfile] = useState(""); + const [priorExperience, setPriorExperience] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isLoggedIn) { + window.location.href = "/join"; + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch("/api/internships/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + programId, + statement, + weeklyAvailabilityHours: Number(weeklyHours), + githubProfile, + priorExperience, + }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Failed to submit application"); + } + + setSuccess(true); + } catch (err: any) { + setError(err.message || "Failed to submit application"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + + {isOpen && ( +
+
+
+
+

+ Apply for Engineering Fellowship +

+

+ Rigorous supervised open-source apprenticeship +

+
+ +
+ + {success ? ( +
+
+ ✓ +
+

+ Application Submitted! +

+

+ Our engineering maintainers review applications weekly based on your verified open-source activity and statement of purpose. +

+ +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ + setGithubProfile(e.target.value)} + required + className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-xs text-white placeholder-slate-500 focus:border-sky-500 focus:outline-none font-mono" + /> +
+ +
+ + setWeeklyHours(Number(e.target.value))} + required + className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-xs text-white focus:border-sky-500 focus:outline-none font-mono" + /> +
+ +
+ +