diff --git a/drizzle/0007_learning_platform.sql b/drizzle/0007_learning_platform.sql new file mode 100644 index 0000000..f88ade8 --- /dev/null +++ b/drizzle/0007_learning_platform.sql @@ -0,0 +1,110 @@ +CREATE TABLE IF NOT EXISTS "learning_tracks" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "difficulty" text DEFAULT 'beginner' NOT NULL, + "estimated_hours" integer DEFAULT 10 NOT NULL, + "is_published" boolean DEFAULT false NOT NULL, + "prerequisites" jsonb DEFAULT '[]'::jsonb NOT NULL, + "objectives" jsonb DEFAULT '[]'::jsonb NOT NULL, + "order_index" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "learning_tracks_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "learning_modules" ( + "id" text PRIMARY KEY NOT NULL, + "track_id" text NOT NULL REFERENCES "learning_tracks"("id") ON DELETE cascade, + "slug" text NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "order_index" integer DEFAULT 0 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 "learning_lessons" ( + "id" text PRIMARY KEY NOT NULL, + "module_id" text NOT NULL REFERENCES "learning_modules"("id") ON DELETE cascade, + "slug" text NOT NULL, + "title" text NOT NULL, + "content" text NOT NULL, + "estimated_minutes" integer DEFAULT 15 NOT NULL, + "order_index" integer DEFAULT 0 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 "learning_enrollments" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "track_id" text NOT NULL REFERENCES "learning_tracks"("id") ON DELETE cascade, + "status" text DEFAULT 'enrolled' NOT NULL, + "enrolled_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "lesson_progress" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "lesson_id" text NOT NULL REFERENCES "learning_lessons"("id") ON DELETE cascade, + "completed" boolean DEFAULT true NOT NULL, + "completed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "learning_assignments" ( + "id" text PRIMARY KEY NOT NULL, + "lesson_id" text NOT NULL REFERENCES "learning_lessons"("id") ON DELETE cascade, + "title" text NOT NULL, + "description" text NOT NULL, + "acceptance_criteria" jsonb DEFAULT '[]'::jsonb NOT NULL, + "submission_type" text DEFAULT 'text_submission' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "assignment_submissions" ( + "id" text PRIMARY KEY NOT NULL, + "assignment_id" text NOT NULL REFERENCES "learning_assignments"("id") ON DELETE cascade, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "content" text NOT NULL, + "status" text DEFAULT 'submitted' NOT NULL, + "reviewed_by" text REFERENCES "users"("id"), + "feedback" text, + "submitted_at" timestamp with time zone DEFAULT now() NOT NULL, + "reviewed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "learning_tracks_slug_idx" ON "learning_tracks" ("slug"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_tracks_is_published_idx" ON "learning_tracks" ("is_published"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_modules_track_id_idx" ON "learning_modules" ("track_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_modules_order_idx" ON "learning_modules" ("track_id", "order_index"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_lessons_module_id_idx" ON "learning_lessons" ("module_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_lessons_order_idx" ON "learning_lessons" ("module_id", "order_index"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "learning_enrollments_user_track_idx" ON "learning_enrollments" ("user_id", "track_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_enrollments_user_id_idx" ON "learning_enrollments" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_enrollments_track_id_idx" ON "learning_enrollments" ("track_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "lesson_progress_user_lesson_idx" ON "lesson_progress" ("user_id", "lesson_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "lesson_progress_user_id_idx" ON "lesson_progress" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "lesson_progress_lesson_id_idx" ON "lesson_progress" ("lesson_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "learning_assignments_lesson_id_idx" ON "learning_assignments" ("lesson_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "assignment_submissions_user_idx" ON "assignment_submissions" ("user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "assignment_submissions_assignment_idx" ON "assignment_submissions" ("assignment_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "assignment_submissions_status_idx" ON "assignment_submissions" ("status"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c52be88..18c99d9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1793326000000, "tag": "0006_privacy_and_governance", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1793327000000, + "tag": "0007_learning_platform", + "breakpoints": true } ] } diff --git a/src/app/api/learn/assignments/[id]/submit/route.ts b/src/app/api/learn/assignments/[id]/submit/route.ts new file mode 100644 index 0000000..f4ce59d --- /dev/null +++ b/src/app/api/learn/assignments/[id]/submit/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { submitAssignment } from "@/lib/learning/service"; + +const submissionSchema = z.object({ + content: z.string().min(1, "Submission content is required"), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const { id: assignmentId } = await params; + const body = await request.json(); + const { content } = submissionSchema.parse(body); + + const submission = await submitAssignment( + sessionUser.id, + assignmentId, + content + ); + + return NextResponse.json({ success: true, submission }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.flatten() }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message || "Failed to submit assignment" }, + { status: 400 } + ); + } +} diff --git a/src/app/api/learn/enroll/route.ts b/src/app/api/learn/enroll/route.ts new file mode 100644 index 0000000..9c84f59 --- /dev/null +++ b/src/app/api/learn/enroll/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { enrollInTrack } from "@/lib/learning/service"; + +const enrollSchema = z.object({ + trackId: z.string().min(1, "Track ID is 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 { trackId } = enrollSchema.parse(body); + + const enrollment = await enrollInTrack(sessionUser.id, trackId); + return NextResponse.json({ success: true, enrollment }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.flatten() }, + { status: 400 } + ); + } + + console.error("Failed to enroll in track:", error); + return NextResponse.json( + { error: error.message || "Failed to enroll in track" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/lessons/[id]/progress/route.ts b/src/app/api/learn/lessons/[id]/progress/route.ts new file mode 100644 index 0000000..f4964aa --- /dev/null +++ b/src/app/api/learn/lessons/[id]/progress/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 { toggleLessonProgress } from "@/lib/learning/service"; + +const progressSchema = z.object({ + completed: z.boolean(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const token = request.cookies.get(COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const sessionUser = await verifySessionToken(token); + if (!sessionUser) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const { id: lessonId } = await params; + const body = await request.json(); + const { completed } = progressSchema.parse(body); + + const result = await toggleLessonProgress( + sessionUser.id, + lessonId, + completed + ); + + return NextResponse.json({ success: true, ...result }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.flatten() }, + { status: 400 } + ); + } + + console.error("Failed to update lesson progress:", error); + return NextResponse.json( + { error: error.message || "Failed to update lesson progress" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/routes.test.ts b/src/app/api/learn/routes.test.ts new file mode 100644 index 0000000..52aecd3 --- /dev/null +++ b/src/app/api/learn/routes.test.ts @@ -0,0 +1,170 @@ +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 { eq } from "drizzle-orm"; +import { GET as getTracksHandler } from "./tracks/route"; +import { POST as enrollHandler } from "./enroll/route"; +import { POST as progressHandler } from "./lessons/[id]/progress/route"; +import { POST as submitAssignmentHandler } from "./assignments/[id]/submit/route"; +import { getTrackBySlug } from "@/lib/learning/service"; + +describe("Learning Platform API Routes", () => { + let db: any; + const testUserId = `usr_api_student_${Date.now()}`; + let sessionCookie: string; + + beforeAll(async () => { + db = await getDb(); + await runMigrations(); + + await db.insert(schema.users).values({ + id: testUserId, + githubId: Math.floor(Math.random() * 1000000), + githubUsername: "apistudent", + email: "apistudent@technexus.org", + role: "contributor", + }); + + const token = await createSessionToken({ + id: testUserId, + githubId: 123456, + githubUsername: "apistudent", + displayName: "API Student", + email: "apistudent@technexus.org", + avatarUrl: null, + role: "contributor", + level: "explorer", + isOnboarded: true, + foundingNumber: null, + }); + + sessionCookie = `${COOKIE_NAME}=${token}`; + }); + + it("GET /api/learn/tracks returns published tracks", async () => { + const req = new NextRequest("http://localhost:3000/api/learn/tracks", { + headers: { cookie: sessionCookie }, + }); + + const res = await getTracksHandler(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.tracks).toBeDefined(); + expect(data.tracks.length).toBeGreaterThanOrEqual(2); + }); + + it("POST /api/learn/enroll enrolls logged-in student in track", async () => { + const track = await getTrackBySlug("linux-fundamentals", testUserId, db); + expect(track).toBeDefined(); + + // Unauthorized without cookie + const unauthReq = new NextRequest("http://localhost:3000/api/learn/enroll", { + method: "POST", + body: JSON.stringify({ trackId: track!.id }), + }); + const unauthRes = await enrollHandler(unauthReq); + expect(unauthRes.status).toBe(401); + + // Authorized enrollment + const authReq = new NextRequest("http://localhost:3000/api/learn/enroll", { + method: "POST", + headers: { + cookie: sessionCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ trackId: track!.id }), + }); + + const authRes = await enrollHandler(authReq); + expect(authRes.status).toBe(200); + const data = await authRes.json(); + expect(data.success).toBe(true); + expect(data.enrollment.trackId).toBe(track!.id); + }); + + it("POST /api/learn/lessons/[id]/progress updates completion status", async () => { + const track = await getTrackBySlug("linux-fundamentals", testUserId, db); + const lesson = track!.modules[0].lessons[0]; + + const req = new NextRequest( + `http://localhost:3000/api/learn/lessons/${lesson.id}/progress`, + { + method: "POST", + headers: { + cookie: sessionCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ completed: true }), + } + ); + + const res = await progressHandler(req, { + params: Promise.resolve({ id: lesson.id }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.completed).toBe(true); + expect(data.completedLessons).toBeGreaterThanOrEqual(1); + }); + + it("POST /api/learn/assignments/[id]/submit handles submissions with validation", async () => { + const track = await getTrackBySlug("git-open-source", testUserId, db); + const lastModule = track!.modules[track!.modules.length - 1]; + const lastLesson = lastModule.lessons[lastModule.lessons.length - 1]; + + const [assignment] = await db + .select() + .from(schema.learningAssignments) + .where(eq(schema.learningAssignments.lessonId, lastLesson.id)); + + expect(assignment).toBeDefined(); + + // Invalid PR URL should return 400 + const invalidReq = new NextRequest( + `http://localhost:3000/api/learn/assignments/${assignment.id}/submit`, + { + method: "POST", + headers: { + cookie: sessionCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ content: "not-a-pr-url" }), + } + ); + + const invalidRes = await submitAssignmentHandler(invalidReq, { + params: Promise.resolve({ id: assignment.id }), + }); + expect(invalidRes.status).toBe(400); + + // Valid PR URL should return 200 + const validReq = new NextRequest( + `http://localhost:3000/api/learn/assignments/${assignment.id}/submit`, + { + method: "POST", + headers: { + cookie: sessionCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ + content: "https://github.com/TechNexusOrg/platform/pull/15", + }), + } + ); + + const validRes = await submitAssignmentHandler(validReq, { + params: Promise.resolve({ id: assignment.id }), + }); + expect(validRes.status).toBe(200); + const validData = await validRes.json(); + expect(validData.success).toBe(true); + expect(validData.submission.content).toBe( + "https://github.com/TechNexusOrg/platform/pull/15" + ); + }); +}); diff --git a/src/app/api/learn/tracks/route.ts b/src/app/api/learn/tracks/route.ts new file mode 100644 index 0000000..ad9832f --- /dev/null +++ b/src/app/api/learn/tracks/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { listTracks } from "@/lib/learning/service"; + +export async function GET(request: NextRequest) { + try { + const token = request.cookies.get(COOKIE_NAME)?.value; + let userId: string | undefined; + + if (token) { + const sessionUser = await verifySessionToken(token); + if (sessionUser) { + userId = sessionUser.id; + } + } + + const tracks = await listTracks({ userId, publishedOnly: true }); + return NextResponse.json({ tracks }); + } catch (error: any) { + console.error("Failed to list learning tracks:", error); + return NextResponse.json( + { error: "Failed to list learning tracks", details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/learn/[trackSlug]/[lessonSlug]/LessonViewer.tsx b/src/app/learn/[trackSlug]/[lessonSlug]/LessonViewer.tsx new file mode 100644 index 0000000..6b5f45c --- /dev/null +++ b/src/app/learn/[trackSlug]/[lessonSlug]/LessonViewer.tsx @@ -0,0 +1,305 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { MarkdownRenderer } from "@/components/MarkdownRenderer"; +import type { DetailedLesson } from "@/lib/learning/service"; + +interface LessonViewerProps { + lesson: DetailedLesson; + isLoggedIn: boolean; +} + +export function LessonViewer({ lesson, isLoggedIn }: LessonViewerProps) { + const [isCompleted, setIsCompleted] = useState(lesson.isCompleted); + const [isToggling, setIsToggling] = useState(false); + + // Assignment states + const [submissionContent, setSubmissionContent] = useState( + lesson.assignment?.userSubmission?.content || "" + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submissionStatus, setSubmissionStatus] = useState( + lesson.assignment?.userSubmission?.status || null + ); + const [submissionMessage, setSubmissionMessage] = useState( + null + ); + const [submissionError, setSubmissionError] = useState(null); + + const handleToggleCompletion = async () => { + if (!isLoggedIn) { + window.location.href = "/join"; + return; + } + + setIsToggling(true); + try { + const res = await fetch(`/api/learn/lessons/${lesson.id}/progress`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ completed: !isCompleted }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to update progress"); + } + + setIsCompleted(!isCompleted); + } catch (err: any) { + console.error(err); + alert(err.message || "Failed to update progress"); + } finally { + setIsToggling(false); + } + }; + + const handleAssignmentSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!lesson.assignment) return; + + if (!isLoggedIn) { + window.location.href = "/join"; + return; + } + + if (!submissionContent.trim()) { + setSubmissionError("Please provide submission content"); + return; + } + + setIsSubmitting(true); + setSubmissionError(null); + setSubmissionMessage(null); + + try { + const res = await fetch( + `/api/learn/assignments/${lesson.assignment.id}/submit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: submissionContent.trim() }), + } + ); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Failed to submit assignment"); + } + + setSubmissionStatus("submitted"); + setSubmissionMessage("Assignment submitted successfully for review!"); + } catch (err: any) { + setSubmissionError(err.message || "Failed to submit assignment"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Top Breadcrumb & Metadata */} + + + {/* Lesson Header Banner */} +
+
+
+
+ + {lesson.module.title} + + + ⏱ {lesson.estimatedMinutes} minutes + +
+

+ {lesson.title} +

+
+ + {/* Completion Toggle */} + +
+
+ + {/* Lesson Content */} +
+ +
+ + {/* Practical Assignment Section */} + {lesson.assignment && ( +
+
+ + Capstone Assignment + + {submissionStatus && ( + + Status: {submissionStatus.replace("_", " ")} + + )} +
+ +

+ {lesson.assignment.title} +

+ +

+ {lesson.assignment.description} +

+ + {/* Acceptance Criteria */} + {lesson.assignment.acceptanceCriteria.length > 0 && ( +
+

+ Acceptance Criteria +

+
    + {lesson.assignment.acceptanceCriteria.map((criterion, idx) => ( +
  • + • + {criterion} +
  • + ))} +
+
+ )} + + {/* Submission Form */} +
+
+ + + {lesson.assignment.submissionType === "github_pr" ? ( + setSubmissionContent(e.target.value)} + className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3.5 py-2.5 text-sm text-white placeholder-slate-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 font-mono" + required + /> + ) : ( +