Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions drizzle/0008_internship_management.sql
Original file line number Diff line number Diff line change
@@ -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");
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@
"when": 1793327000000,
"tag": "0007_learning_platform",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1793328000000,
"tag": "0008_internship_management",
"breakpoints": true
}
]
}
50 changes: 50 additions & 0 deletions src/app/api/internships/apply/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
26 changes: 26 additions & 0 deletions src/app/api/internships/portal/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
15 changes: 15 additions & 0 deletions src/app/api/internships/programs/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
150 changes: 150 additions & 0 deletions src/app/api/internships/routes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading