diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..906a9f8 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,25 @@ +name: Node.js CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-latest + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: "npm" + - run: npm ci + - run: npm run build --if-present diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..1423f18 --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,242 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { createSupabaseServer } from "@/lib/supabaseServer"; + +function encodeMessage(message: string): string { + return encodeURIComponent(message); +} + +export async function signUp(formData: FormData): Promise { + const email = String(formData.get("email") || "").trim(); + const password = String(formData.get("password") || ""); + const fullName = String(formData.get("full_name") || "").trim(); + const role = String(formData.get("role") || "participant"); + + if (!email || !password || !fullName) { + redirect(`/register?error=${encodeMessage("All fields are required.")}`); + } + + const supabase = await createSupabaseServer(); + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + full_name: fullName, + role, + }, + }, + }); + + if (error) { + redirect(`/register?error=${encodeMessage(error.message)}`); + } + + if (role === "instructor") { + redirect("/instructor/dashboard"); + } + + redirect("/participant/dashboard"); +} + +export async function signIn(formData: FormData): Promise { + const email = String(formData.get("email") || "").trim(); + const password = String(formData.get("password") || ""); + + if (!email || !password) { + redirect( + `/login?error=${encodeMessage("Email and password are required.")}` + ); + } + + const supabase = await createSupabaseServer(); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + redirect(`/login?error=${encodeMessage(error.message)}`); + } + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + redirect( + `/login?error=${encodeMessage("Could not get logged in user.")}` + ); + } + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("role") + .eq("user_id", user.id) + .single(); + + if (profileError || !profile) { + redirect( + `/login?error=${encodeMessage("Could not load user profile.")}` + ); + } + + if (profile.role === "instructor") { + redirect("/instructor/dashboard"); + } + + redirect("/participant/dashboard"); +} + +export async function signOut(): Promise { + const supabase = await createSupabaseServer(); + await supabase.auth.signOut(); + redirect("/login"); +} + +export async function updatePassword(formData: FormData): Promise { + const currentPassword = String(formData.get("currentPassword") || ""); + const newPassword = String(formData.get("newPassword") || ""); + const confirmPassword = String(formData.get("confirmPassword") || ""); + + if (!currentPassword || !newPassword || !confirmPassword) { + redirect( + `/participant/profile?error=${encodeMessage( + "All password fields are required." + )}` + ); + } + + if (newPassword !== confirmPassword) { + redirect( + `/participant/profile?error=${encodeMessage( + "New passwords do not match." + )}` + ); + } + + if (newPassword.length < 6) { + redirect( + `/participant/profile?error=${encodeMessage( + "New password must be at least 6 characters." + )}` + ); + } + + const supabase = await createSupabaseServer(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user || !user.email) { + redirect( + `/participant/profile?error=${encodeMessage( + "Could not load current user." + )}` + ); + } + + const { error: verifyError } = await supabase.auth.signInWithPassword({ + email: user.email, + password: currentPassword, + }); + + if (verifyError) { + redirect( + `/participant/profile?error=${encodeMessage( + "Current password is incorrect." + )}` + ); + } + + const { error: updateError } = await supabase.auth.updateUser({ + password: newPassword, + }); + + if (updateError) { + redirect( + `/participant/profile?error=${encodeMessage(updateError.message)}` + ); + } + + redirect( + `/participant/profile?success=${encodeMessage( + "Password updated successfully." + )}` + ); +} +export async function updateInstructorPassword( + formData: FormData +): Promise { + const currentPassword = String(formData.get("currentPassword") || ""); + const newPassword = String(formData.get("newPassword") || ""); + const confirmPassword = String(formData.get("confirmPassword") || ""); + + const redirectPath = "/instructor/profile"; + + if (!currentPassword || !newPassword || !confirmPassword) { + redirect( + `${redirectPath}?error=${encodeMessage( + "All password fields are required." + )}` + ); + } + + if (newPassword !== confirmPassword) { + redirect( + `${redirectPath}?error=${encodeMessage("New passwords do not match.")}` + ); + } + + if (newPassword.length < 6) { + redirect( + `${redirectPath}?error=${encodeMessage( + "New password must be at least 6 characters." + )}` + ); + } + + const supabase = await createSupabaseServer(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user || !user.email) { + redirect( + `${redirectPath}?error=${encodeMessage("Could not load current user.")}` + ); + } + + const { error: verifyError } = await supabase.auth.signInWithPassword({ + email: user.email, + password: currentPassword, + }); + + if (verifyError) { + redirect( + `${redirectPath}?error=${encodeMessage("Current password is incorrect.")}` + ); + } + + const { error: updateError } = await supabase.auth.updateUser({ + password: newPassword, + }); + + if (updateError) { + redirect(`${redirectPath}?error=${encodeMessage(updateError.message)}`); + } + + redirect( + `${redirectPath}?success=${encodeMessage( + "Password updated successfully." + )}` + ); +} \ No newline at end of file diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 80c4a43..cd779b9 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,17 +1,40 @@ import { NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabaseAdmin"; +/** + * Forces this route to run on Node.js (not edge runtime). + * Needed because Supabase Admin client requires Node environment. + */ export const runtime = "nodejs"; +/** + * GET /api/... + * + * Purpose: + * - Simple health check for Supabase connection + * - Verifies we can query the "sessions" table + */ export async function GET() { + // Try to fetch 1 session record (just to test DB access) const { data, error } = await supabaseAdmin .from("sessions") .select("id") .limit(1); + // If query fails → return error response if (error) { - return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + return NextResponse.json( + { + ok: false, + error: error.message, + }, + { status: 500 } + ); } - return NextResponse.json({ ok: true, sample: data ?? [] }); + // If successful → return sample data + return NextResponse.json({ + ok: true, + sample: data ?? [], + }); } \ No newline at end of file diff --git a/app/api/hume/batch/poll/route.ts b/app/api/hume/batch/poll/route.ts new file mode 100644 index 0000000..128951d --- /dev/null +++ b/app/api/hume/batch/poll/route.ts @@ -0,0 +1,173 @@ +import { NextResponse } from "next/server"; +import { HumeClient } from "hume"; +import { analyzeSession } from "@/lib/server/sessions/analyzeSession"; +import { createSupabaseServer } from "@/lib/supabaseServer"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +/** + * Ensures this runs in Node (required for Hume + server libs) + */ +export const runtime = "nodejs"; + +/** + * POST /api/... + * + * Purpose: + * - Poll a Hume AI job (emotion analysis) + * - If complete → extract emotions + * - Send emotion context into analyzeSession (OpenAI feedback) + */ +export async function POST(req: Request) { + try { + // Extract request body + const { jobId, sessionId } = await req.json(); + + // Validate required inputs + if (!jobId || !sessionId) { + return NextResponse.json( + { error: "jobId and sessionId required" }, + { status: 400 } + ); + } + + // Authenticate user + const supabaseServer = await createSupabaseServer(); + const { data: authData, error: authError } = await supabaseServer.auth.getUser(); + + if (authError || !authData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const authUserId = authData.user.id; + + // Verify session ownership + const { data: sessionData, error: sessionError } = await supabaseAdmin + .from("sessions") + .select("participant_id") + .eq("id", sessionId) + .single(); + + if (sessionError || !sessionData) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + if (sessionData.participant_id !== authUserId) { + // Check if user is an instructor of this participant + const { data: profile } = await supabaseAdmin + .from("profiles") + .select("instructor_id") + .eq("user_id", sessionData.participant_id) + .single(); + + if (!profile || profile.instructor_id !== authUserId) { + return NextResponse.json({ error: "Unauthorized access to session" }, { status: 403 }); + } + } + + // Initialize Hume client + const client = new HumeClient({ + apiKey: process.env.HUME_API_KEY!, + }); + + // Get job status from Hume + const statusObj = await client.expressionMeasurement.batch.getJobDetails(jobId); + const status = statusObj.state.status; + + /** + * CASE 1: Job completed + * - Extract emotions + * - Build summary + * - Pass into AI feedback + */ + if (status === "COMPLETED") { + const predictions = await client.expressionMeasurement.batch.getJobPredictions(jobId); + + const totals: Record = {}; + let count = 0; + + /** + * Loop through deeply nested Hume response: + * files → results → predictions → prosody → groupedPredictions → emotions + */ + interface HumeFile { + results?: { + predictions?: Array<{ + models?: { + prosody?: { + groupedPredictions?: Array<{ + predictions?: Array<{ + emotions?: Array<{ name: string; score: number }>; + }>; + }>; + }; + }; + }>; + }; + } + + for (const file of predictions) { + for (const result of (file as unknown as HumeFile)?.results?.predictions || []) { + for (const grouped of result.models?.prosody?.groupedPredictions || []) { + for (const pred of grouped.predictions || []) { + for (const emotion of pred.emotions || []) { + // Aggregate emotion scores + if (!totals[emotion.name]) totals[emotion.name] = 0; + totals[emotion.name] += emotion.score; + } + count++; + } + } + } + } + + /** + * Get top 5 emotions with average scores + */ + const topEmotions = Object.keys(totals) + .map((name) => ({ + name, + score: count > 0 ? totals[name] / count : 0, + })) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map((e) => `${e.name} (${Math.round(e.score * 100)}%)`) + .join(", "); + + /** + * Build context string to inject into AI feedback + */ + const offlineContext = topEmotions + ? `The user's overall emotions during the session were: ${topEmotions}. Use this to improve coaching feedback.` + : ""; + + // Run AI feedback with emotion context + await analyzeSession(sessionId, offlineContext); + + return NextResponse.json({ status: "COMPLETED" }); + } + + /** + * CASE 2: Job failed + * - Still generate feedback (fallback) + */ + else if (status === "FAILED") { + await analyzeSession(sessionId); + return NextResponse.json({ status: "FAILED" }); + } + + /** + * CASE 3: Job still processing + */ + else { + return NextResponse.json({ status }); + } + } catch (error: unknown) { + console.error("Hume batch poll error:", error); + + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: errorMessage }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/hume/batch/start/route.ts b/app/api/hume/batch/start/route.ts new file mode 100644 index 0000000..f902921 --- /dev/null +++ b/app/api/hume/batch/start/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { HumeClient } from "hume"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; +import { createSupabaseServer } from "@/lib/supabaseServer"; + +/** + * This route must run on Node.js because it uses + * server-side SDKs and environment variables. + */ +export const runtime = "nodejs"; + +/** + * POST /api/... + * + * Purpose: + * - Receive a sessionId + * - Find the session's recording + * - Generate a temporary signed URL for that recording + * - Send the file to Hume for analysis + * - Return the Hume jobId + */ +export async function POST(req: Request) { + try { + // Read request body + const { sessionId } = await req.json(); + + // Validate required input + if (!sessionId) { + return NextResponse.json( + { error: "sessionId required" }, + { status: 400 } + ); + } + + // Authenticate user + const supabaseServer = await createSupabaseServer(); + const { data: authData, error: authError } = await supabaseServer.auth.getUser(); + + if (authError || !authData?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const authUserId = authData.user.id; + + // Verify session ownership + const { data: sessionData, error: sessionError } = await supabaseAdmin + .from("sessions") + .select("participant_id") + .eq("id", sessionId) + .single(); + + if (sessionError || !sessionData) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + if (sessionData.participant_id !== authUserId) { + // Check if user is an instructor of this participant + const { data: profile } = await supabaseAdmin + .from("profiles") + .select("instructor_id") + .eq("user_id", sessionData.participant_id) + .single(); + + if (!profile || profile.instructor_id !== authUserId) { + return NextResponse.json({ error: "Unauthorized access to session" }, { status: 403 }); + } + } + + /** + * Step 1: + * Find the recording linked to this session + */ + const { data: recording, error: recordingError } = await supabaseAdmin + .from("recordings") + .select("storage_path") + .eq("session_id", sessionId) + .single(); + + if (recordingError || !recording?.storage_path) { + return NextResponse.json( + { error: "Recording not found" }, + { status: 404 } + ); + } + + /** + * Step 2: + * Create a temporary signed URL so Hume can download the file + * from Supabase Storage + */ + const { data: signedUrlData, error: signedUrlError } = + await supabaseAdmin.storage + .from("recordings") + .createSignedUrl(recording.storage_path, 3600); + + if (signedUrlError || !signedUrlData?.signedUrl) { + return NextResponse.json( + { error: "Signed URL generation failed" }, + { status: 500 } + ); + } + + /** + * Step 3: + * Create Hume client + */ + const client = new HumeClient({ + apiKey: process.env.HUME_API_KEY!, + }); + + /** + * Step 4: + * Start a Hume batch inference job using the signed file URL + */ + const job = await client.expressionMeasurement.batch.startInferenceJob({ + urls: [signedUrlData.signedUrl], + models: { + face: {}, + prosody: {}, + language: {}, + }, + }); + + /** + * Step 5: + * Return the created Hume job ID + */ + return NextResponse.json({ + jobId: job.jobId, + }); + } catch (error: unknown) { + console.error("Hume batch start error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + return NextResponse.json( + { error: errorMessage }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/hume/token/route.ts b/app/api/hume/token/route.ts new file mode 100644 index 0000000..d384e09 --- /dev/null +++ b/app/api/hume/token/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { fetchAccessToken } from "hume"; + +/** + * GET /api/... + * + * Purpose: + * - Generate a temporary Hume access token + * - Send it to the frontend so it can use Hume safely + * + * Why? + * - You NEVER expose your secret key to the frontend + * - Instead, you generate short-lived tokens on the server + */ +export async function GET() { + // Read Hume credentials from environment variables + const apiKey = process.env.HUME_API_KEY; + const secretKey = process.env.HUME_SECRET_KEY; + + /** + * Step 1: Validate environment variables + */ + if (!apiKey || !secretKey) { + return NextResponse.json( + { error: "Hume API keys are not configured" }, + { status: 500 } + ); + } + + try { + /** + * Step 2: Request a temporary access token from Hume + */ + const accessToken = await fetchAccessToken({ + apiKey, + secretKey, + }); + + /** + * Step 3: Validate token response + */ + if (!accessToken) { + return NextResponse.json( + { error: "Failed to fetch access token" }, + { status: 500 } + ); + } + + /** + * Step 4: Return token to client + */ + return NextResponse.json({ + accessToken, + }); + } catch (error) { + console.error("Error fetching Hume token:", error); + + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/instructor/assignments/[assignmentId]/route.ts b/app/api/instructor/assignments/[assignmentId]/route.ts new file mode 100644 index 0000000..b89f606 --- /dev/null +++ b/app/api/instructor/assignments/[assignmentId]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +type RouteContext = { + params: Promise<{ + assignmentId: string; + }>; +}; + +export async function POST(req: Request, { params }: RouteContext) { + try { + const { assignmentId } = await params; + const { instructorId } = await requireInstructor(); + + const formData = await req.formData(); + const intent = String(formData.get("_intent") || "").trim(); + + if (intent === "delete") { + const { error: deleteError } = await supabaseAdmin + .from("session_assignments") + .delete() + .eq("id", assignmentId) + .eq("instructor_id", instructorId); + + if (deleteError) { + return NextResponse.json( + { error: deleteError.message }, + { status: 500 } + ); + } + } + + if (intent === "update") { + const title = String(formData.get("title") || "").trim(); + + if (!title) { + return NextResponse.json( + { error: "Title is required." }, + { status: 400 } + ); + } + + const { error: updateError } = await supabaseAdmin + .from("session_assignments") + .update({ title }) + .eq("id", assignmentId) + .eq("instructor_id", instructorId); + + if (updateError) { + return NextResponse.json( + { error: updateError.message }, + { status: 500 } + ); + } + } + + return NextResponse.redirect(new URL("/instructor/assignments", req.url)); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unexpected server error."; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/instructor/assignments/route.ts b/app/api/instructor/assignments/route.ts new file mode 100644 index 0000000..d94d84a --- /dev/null +++ b/app/api/instructor/assignments/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +export async function POST(req: Request) { + try { + const { instructorId } = await requireInstructor(); + const formData = await req.formData(); + + const participant_id = String(formData.get("participant_id") || "").trim(); + const title = String(formData.get("title") || "").trim(); + const goal = String(formData.get("goal") || "").trim(); + const instructions = String(formData.get("instructions") || "").trim(); + const max_minutes_raw = String(formData.get("max_minutes") || "").trim(); + const due_at = String(formData.get("due_at") || "").trim(); + + if (!participant_id || !title) { + return NextResponse.json( + { error: "Participant and title are required." }, + { status: 400 } + ); + } + + const max_minutes = max_minutes_raw ? Number(max_minutes_raw) : 5; + + if (!Number.isFinite(max_minutes) || max_minutes <= 0) { + return NextResponse.json( + { error: "Max minutes must be a valid positive number." }, + { status: 400 } + ); + } + + const { data: participant, error: participantError } = await supabaseAdmin + .from("profiles") + .select("user_id, instructor_id, role") + .eq("user_id", participant_id) + .eq("role", "participant") + .eq("instructor_id", instructorId) + .maybeSingle(); + + if (participantError) { + return NextResponse.json( + { error: participantError.message }, + { status: 500 } + ); + } + + if (!participant) { + return NextResponse.json( + { error: "Selected participant is invalid." }, + { status: 400 } + ); + } + + const { error: insertError } = await supabaseAdmin + .from("session_assignments") + .insert({ + participant_id, + instructor_id: instructorId, + title, + goal: goal || null, + instructions: instructions || null, + max_minutes, + due_at: due_at || null, + status: "assigned", + }); + + if (insertError) { + return NextResponse.json( + { error: insertError.message }, + { status: 500 } + ); + } + + return NextResponse.redirect(new URL("/instructor/assignments", req.url)); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unexpected server error."; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/instructor/students/[studentId]/route.ts b/app/api/instructor/students/[studentId]/route.ts new file mode 100644 index 0000000..2f0b76d --- /dev/null +++ b/app/api/instructor/students/[studentId]/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +/** + * Route params type + * Example: /api/students/[studentId] + */ +type RouteContext = { + params: Promise<{ + studentId: string; + }>; +}; + +/** + * PATCH /api/... + * + * Purpose: + * - Allow an instructor to update a student’s profile + * - Only if the student is assigned to that instructor + */ +export async function PATCH(req: Request, { params }: RouteContext) { + try { + /** + * Step 1: Ensure user is an authenticated instructor + */ + const { instructorId } = await requireInstructor(); + + /** + * Step 2: Get studentId from route params + */ + const { studentId } = await params; + + /** + * Step 3: Parse request body + */ + const body = await req.json(); + + // Only accept valid string fields and trim them + const full_name = + typeof body.full_name === "string" ? body.full_name.trim() : undefined; + + const job_goal = + typeof body.job_goal === "string" ? body.job_goal.trim() : undefined; + + const coach_notes = + typeof body.coach_notes === "string" + ? body.coach_notes.trim() + : undefined; + + /** + * Step 4: Verify student exists AND belongs to this instructor + */ + const { data: studentProfile, error: studentError } = await supabaseAdmin + .from("profiles") + .select("user_id, instructor_id, role") + .eq("user_id", studentId) + .eq("role", "participant") + .eq("instructor_id", instructorId) + .maybeSingle(); + + if (studentError) { + return NextResponse.json( + { error: studentError.message }, + { status: 500 } + ); + } + + // If student is not found or not assigned to this instructor + if (!studentProfile) { + return NextResponse.json( + { error: "Student not found or not assigned to you." }, + { status: 404 } + ); + } + + /** + * Step 5: Build update object dynamically + * Only include fields that were actually provided + */ + const updates: Record = {}; + + if (full_name !== undefined) { + updates.full_name = full_name || null; + } + + if (job_goal !== undefined) { + updates.job_goal = job_goal || null; + } + + if (coach_notes !== undefined) { + updates.coach_notes = coach_notes || null; + } + + // If nothing valid was provided, return error + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields provided to update." }, + { status: 400 } + ); + } + + /** + * Step 6: Update student profile in database + */ + const { error: updateError } = await supabaseAdmin + .from("profiles") + .update(updates) + .eq("user_id", studentId); + + if (updateError) { + return NextResponse.json( + { error: updateError.message }, + { status: 500 } + ); + } + + /** + * Step 7: Success response + */ + return NextResponse.json({ success: true }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unexpected server error."; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/instructor/students/route.ts b/app/api/instructor/students/route.ts new file mode 100644 index 0000000..88071d9 --- /dev/null +++ b/app/api/instructor/students/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +/** + * POST /api/... + * + * Purpose: + * - Allow an instructor to create a new participant account + * - Automatically assign that participant to the instructor + */ +export async function POST(req: Request) { + try { + /** + * Step 1: Ensure the current user is an authenticated instructor + */ + const { instructorId } = await requireInstructor(); + + /** + * Step 2: Read form data from the request + */ + const formData = await req.formData(); + + const full_name = String(formData.get("full_name") || "").trim(); + const email = String(formData.get("email") || "").trim().toLowerCase(); + const password = String(formData.get("password") || "").trim(); + const job_goal = String(formData.get("job_goal") || "").trim(); + const coach_notes = String(formData.get("coach_notes") || "").trim(); + + /** + * Step 3: Validate required fields + */ + if (!full_name || !email || !password) { + return NextResponse.json( + { error: "Full name, email, and password are required." }, + { status: 400 } + ); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 } + ); + } + + /** + * Step 4: Create the auth user in Supabase Auth + */ + const { data: createdUser, error: createUserError } = + await supabaseAdmin.auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { + full_name, + }, + }); + + if (createUserError || !createdUser.user) { + return NextResponse.json( + { + error: createUserError?.message || "Failed to create auth user.", + }, + { status: 500 } + ); + } + + /** + * Step 5: Create or update the participant profile + * and assign the participant to the current instructor + */ + const { error: profileError } = await supabaseAdmin + .from("profiles") + .upsert( + { + user_id: createdUser.user.id, + full_name, + role: "participant", + instructor_id: instructorId, + job_goal: job_goal || null, + coach_notes: coach_notes || null, + }, + { + onConflict: "user_id", + } + ); + + /** + * Step 6: If profile creation fails, + * clean up by deleting the auth user we just created + */ + if (profileError) { + await supabaseAdmin.auth.admin.deleteUser(createdUser.user.id); + + return NextResponse.json( + { error: profileError.message }, + { status: 500 } + ); + } + + /** + * Step 7: Redirect back to the instructor's student list + */ + return NextResponse.redirect(new URL("/instructor/students", req.url)); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unexpected server error."; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/recordings/signed-url/route.ts b/app/api/recordings/signed-url/route.ts new file mode 100644 index 0000000..9edfbe5 --- /dev/null +++ b/app/api/recordings/signed-url/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; + +/** + * Ensure this runs on Node.js (required for Supabase admin + storage) + */ +export const runtime = "nodejs"; + +/** + * GET /api/... + * + * Purpose: + * - Receive a session_id + * - Find its recording in the database + * - Generate a temporary signed URL + * - Return the URL so the client can access the file + */ +export async function GET(req: Request) { + /** + * Step 1: Extract session_id from query params + * Example: /api/... ?session_id=123 + */ + const { searchParams } = new URL(req.url); + const session_id = searchParams.get("session_id"); + + // Validate input + if (!session_id) { + return NextResponse.json( + { ok: false, error: "session_id required" }, + { status: 400 } + ); + } + + /** + * Step 2: Find recording in the database + */ + const { data: recording, error: recordingError } = await supabaseAdmin + .from("recordings") + .select("storage_path") + .eq("session_id", session_id) + .single(); + + if (recordingError) { + return NextResponse.json( + { ok: false, error: recordingError.message }, + { status: 500 } + ); + } + + /** + * Step 3: Generate signed URL (temporary access link) + * Valid for 5 minutes (300 seconds) + */ + const { data: signedUrlData, error: signedUrlError } = + await supabaseAdmin.storage + .from("recordings") + .createSignedUrl(recording.storage_path, 300); + + if (signedUrlError) { + return NextResponse.json( + { ok: false, error: signedUrlError.message }, + { status: 500 } + ); + } + + /** + * Step 4: Return signed URL + */ + return NextResponse.json({ + ok: true, + signedUrl: signedUrlData.signedUrl, + }); +} \ No newline at end of file diff --git a/app/api/sessions/analyze/route.ts b/app/api/sessions/analyze/route.ts new file mode 100644 index 0000000..d275511 --- /dev/null +++ b/app/api/sessions/analyze/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { analyzeSession } from "@/lib/server/sessions/analyzeSession"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + try { + const { sessionId, offlineContext } = await req.json(); + + if (!sessionId) { + return NextResponse.json( + { error: "sessionId is required" }, + { status: 400 } + ); + } + + const feedback = await analyzeSession(sessionId, offlineContext); + + return NextResponse.json({ + success: true, + feedback, + }); + } catch (error: unknown) { + console.error("POST /api/sessions/analyze error:", error); + + const message = + error instanceof Error ? error.message : "Failed to analyze session"; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/sessions/complete/route.ts b/app/api/sessions/complete/route.ts new file mode 100644 index 0000000..5a1aba3 --- /dev/null +++ b/app/api/sessions/complete/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; +import { createSupabaseServer } from "@/lib/supabaseServer"; +import { analyzeSession } from "@/lib/server/sessions/analyzeSession"; + +export const dynamic = "force-dynamic"; + +type VideoAnalysisStats = { + framesAnalyzed?: number; + faceDetectedFrames?: number; + lookingAwayFrames?: number; + smileFrames?: number; + eyeContactPercent?: number; + faceDetectedPercent?: number; + lookingAwayCount?: number; + avgSmileScore?: number; +}; + +export async function POST(req: NextRequest) { + try { + const supabaseServer = await createSupabaseServer(); + + const { data: authData, error: authError } = + await supabaseServer.auth.getUser(); + + if (authError || !authData.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const authUserId = authData.user.id; + const body = await req.json(); + + const { + sessionId, + compactTranscript, + durationSeconds, + transcriptStatus, + videoAnalysis, + }: { + sessionId?: string; + compactTranscript?: string; + durationSeconds?: number; + transcriptStatus?: string; + videoAnalysis?: VideoAnalysisStats | null; + } = body; + + if (!sessionId) { + return NextResponse.json( + { error: "sessionId is required" }, + { status: 400 } + ); + } + + const endedAt = new Date().toISOString(); + + const { data: session, error: sessionFetchError } = await supabaseAdmin + .from("sessions") + .select("id, participant_id, assignment_id") + .eq("id", sessionId) + .single(); + + if (sessionFetchError || !session) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + if (session.participant_id !== authUserId) { + return NextResponse.json( + { error: "You can only complete your own session" }, + { status: 403 } + ); + } + + const safeTranscript = (compactTranscript ?? "").trim(); + + const safeDuration = + typeof durationSeconds === "number" && durationSeconds >= 0 + ? Math.round(durationSeconds) + : 0; + + const finalTranscriptStatus = transcriptStatus ?? "completed"; + + const { error: updateError } = await supabaseAdmin + .from("sessions") + .update({ + status: "completed", + ended_at: endedAt, + duration_seconds: safeDuration, + compact_transcript: safeTranscript, + video_analysis: videoAnalysis ?? null, + }) + .eq("id", sessionId) + .eq("participant_id", authUserId); + + if (updateError) { + console.error("Session update error:", updateError); + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + const { data: existingTranscript, error: transcriptFetchError } = + await supabaseAdmin + .from("transcripts") + .select("id") + .eq("session_id", sessionId) + .maybeSingle(); + + if (transcriptFetchError) { + return NextResponse.json( + { error: transcriptFetchError.message }, + { status: 500 } + ); + } + + if (existingTranscript) { + await supabaseAdmin + .from("transcripts") + .update({ + status: finalTranscriptStatus, + transcript_text: safeTranscript, + updated_at: endedAt, + }) + .eq("id", existingTranscript.id); + } else { + await supabaseAdmin.from("transcripts").insert({ + session_id: sessionId, + status: finalTranscriptStatus, + transcript_text: safeTranscript, + created_at: endedAt, + updated_at: endedAt, + }); + } + + if (session.assignment_id) { + await supabaseAdmin + .from("session_assignments") + .update({ status: "completed" }) + .eq("id", session.assignment_id) + .eq("participant_id", authUserId); + } + + let feedback = null; + + try { + feedback = await analyzeSession(sessionId); + } catch (analysisError) { + console.error("Auto analysis failed:", analysisError); + } + + return NextResponse.json({ + success: true, + sessionId, + feedback, + }); + } catch (error: unknown) { + console.error("POST /api/sessions/complete error:", error); + + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/sessions/create/route.ts b/app/api/sessions/create/route.ts index d0a3009..45a2d97 100644 --- a/app/api/sessions/create/route.ts +++ b/app/api/sessions/create/route.ts @@ -1,28 +1,134 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabaseAdmin"; +import { createSupabaseServer } from "@/lib/supabaseServer"; -export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; -export async function POST(req: Request) { - const { participant_id } = await req.json(); +export async function POST(req: NextRequest) { + try { + const supabaseServer = await createSupabaseServer(); - if (!participant_id) { - return NextResponse.json({ error: "participant_id is required" }, { status: 400 }); - } + const { data: authData, error: authError } = + await supabaseServer.auth.getUser(); - const { data, error } = await supabaseAdmin - .from("sessions") - .insert({ - participant_id, - status: "created", - started_at: new Date().toISOString(), - }) - .select() - .single(); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } + if (authError || !authData.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const authUserId = authData.user.id; + + const { data: participantProfile, error: profileError } = + await supabaseAdmin + .from("profiles") + .select("user_id, role, instructor_id, full_name") + .eq("user_id", authUserId) + .single(); + + if (profileError || !participantProfile) { + return NextResponse.json( + { error: "Participant profile not found" }, + { status: 404 } + ); + } + + if (participantProfile.role !== "participant") { + return NextResponse.json( + { error: "Only participants can create sessions" }, + { status: 403 } + ); + } + + const body = await req.json(); + + const { + assignmentId, + title, + humeConfigId, + }: { + assignmentId?: string | null; + title?: string | null; + humeConfigId?: string | null; + } = body; + + let sessionTitle = title?.trim() || "Practice Session"; + + if (assignmentId) { + const { data: assignment, error: assignmentError } = + await supabaseAdmin + .from("session_assignments") + .select("id, participant_id, status, title") + .eq("id", assignmentId) + .single(); + + if (assignmentError || !assignment) { + return NextResponse.json( + { error: "Assignment not found" }, + { status: 404 } + ); + } - return NextResponse.json({ session: data }); + if (assignment.participant_id !== authUserId) { + return NextResponse.json( + { error: "Assignment does not belong to this participant" }, + { status: 403 } + ); + } + + sessionTitle = assignment.title?.trim() || sessionTitle; + } + + const now = new Date().toISOString(); + + const { data: session, error: sessionError } = await supabaseAdmin + .from("sessions") + .insert({ + participant_id: authUserId, + assignment_id: assignmentId ?? null, + title: sessionTitle, + status: "active", + started_at: now, + hume_config_id: humeConfigId ?? null, + }) + .select( + "id, participant_id, assignment_id, title, status, started_at, created_at" + ) + .single(); + + if (sessionError || !session) { + console.error("Create session error:", sessionError); + + return NextResponse.json( + { error: sessionError?.message || "Failed to create session" }, + { status: 500 } + ); + } + + if (assignmentId) { + const { error: assignmentUpdateError } = await supabaseAdmin + .from("session_assignments") + .update({ status: "in_progress" }) + .eq("id", assignmentId) + .eq("participant_id", authUserId); + + if (assignmentUpdateError) { + console.error( + "Failed to update assignment status:", + assignmentUpdateError + ); + } + } + + return NextResponse.json({ + success: true, + sessionId: session.id, + session, + }); + } catch (error: unknown) { + console.error("POST /api/sessions/create error:", error); + + const message = + error instanceof Error ? error.message : "Internal server error"; + + return NextResponse.json({ error: message }, { status: 500 }); + } } \ No newline at end of file diff --git a/app/api/sessions/generate-prompt/route.ts b/app/api/sessions/generate-prompt/route.ts new file mode 100644 index 0000000..e3e2620 --- /dev/null +++ b/app/api/sessions/generate-prompt/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; +import OpenAI from "openai"; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +); + +export const dynamic = "force-dynamic"; + +type GeneratedPromptPayload = { + scenario: string; + systemPrompt: string; + quickTips: string[]; +}; + +export async function POST(req: NextRequest) { + let assignmentTitle: string | null = null; + + try { + const { assignmentId, participantId } = await req.json(); + + let goal = "Practice answering interview questions clearly and concisely."; + let instructions = "Conduct a general practice interview."; + let participantContext = ""; + + // Fetch Assignment Details + if (assignmentId) { + const { data: assignment } = await supabase + .from("session_assignments") + .select("goal, instructions, title") + .eq("id", assignmentId) + .maybeSingle(); + + if (assignment) { + assignmentTitle = assignment.title ?? null; + if (assignment.goal) goal = assignment.goal; + if (assignment.instructions) instructions = assignment.instructions; + } + } + + // Fetch Participant Details (Optional but helpful for custom persona) + if (participantId) { + const { data: profile } = await supabase + .from("profiles") + .select("job_goal, participant_condition") + .eq("user_id", participantId) + .maybeSingle(); + + if (profile) { + if (profile.job_goal) { + participantContext += `\nParticipant's target job role: ${profile.job_goal}.`; + } + if (profile.participant_condition) { + participantContext += `\nParticipant background/needs: ${profile.participant_condition}.`; + } + } + } + + const openAiPrompt = ` +You are an expert prompt engineer and interviewer and a coach. + +An instructor wants to run a practice mock-interview roleplay session for a neurodiverse participant. +Your job is to generate a tailored scenario, a direct system prompt for the AI interviewer agent, and 3 quick tips for the participant. + +Assignment Details: +Goal: ${goal} +Instructions: ${instructions} +${participantContext} + +Output MUST be a valid JSON object matching this exact shape: +{ + "scenario": "A brief 2-3 sentence description of the scenario presented to the participant so they know what they are walking into.", + "system_prompt": "The detailed instructions that act as the system prompt for the AI agent (e.g. 'You are a hiring manager for... Your personality should be... Make sure to ask questions related to...'). Base this entirely on the Assignment Details provided.", + "quick_tips": [ + "Tip 1...", + "Tip 2...", + "Tip 3..." + ] +} + +- Keep the scenario clear and accessible. +- The system_prompt should explicitly instruct the AI on how to behave, what questions to ask first, and how they should push or support the user. It should directly execute the instructor's goal. +- Quick tips should be short, practical 1-sentence pieces of advice (e.g. "Take a breath before answering," "Focus on answering exactly what was asked."). +`; + + const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, + }); + + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", // can be gpt-4o-mini or gpt-4.1-mini + temperature: 0.7, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: "You are an expert AI configuration assistant. You output perfectly valid JSON.", + }, + { + role: "user", + content: openAiPrompt, + }, + ], + }); + + const raw = completion.choices[0]?.message?.content; + if (!raw) { + throw new Error("No response generated from OpenAI"); + } + + const parsed = JSON.parse(raw); + + const payload: GeneratedPromptPayload = { + scenario: parsed.scenario || "Welcome to your practice session. Read the goal carefully, then press Start to begin the mock interview.", + systemPrompt: parsed.system_prompt || "You are a helpful and supportive AI interview coach conducting a mock interview. Be encouraging, ask clear questions one at a time, and listen patiently.", + quickTips: Array.isArray(parsed.quick_tips) + ? parsed.quick_tips.filter((t: unknown) => typeof t === "string").slice(0, 3) + : [ + "Take your time before answering.", + "Speak clearly into the microphone.", + "It is okay to pause and think." + ] + }; + + return NextResponse.json({ success: true, payload }); + } catch (error) { + console.error("Error generating prompt:", error); + + // Provide a safe fallback on error + const fallbackPayload: GeneratedPromptPayload = { + scenario: "Welcome to your general practice session. The AI will ask you some introductory questions.", + systemPrompt: "You are a helpful AI interview coach conducting a general practice interview. Keep your questions simple and supportive.", + quickTips: [ + "Take a deep breath and start when ready.", + "Answer naturally.", + "End with a clear, confident statement." + ] + }; + + return NextResponse.json( + { success: false, error: "Failed to generate prompt", payload: fallbackPayload, title: assignmentTitle }, + { status: 500 } + ); + } +} diff --git a/app/api/weekly-feedback/generate/route.ts b/app/api/weekly-feedback/generate/route.ts new file mode 100644 index 0000000..32e48c2 --- /dev/null +++ b/app/api/weekly-feedback/generate/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServer } from "@/lib/supabaseServer"; +import { supabaseAdmin } from "@/lib/supabaseAdmin"; +import { generateWeeklyFeedback } from "@/lib/server/weekly/generateWeeklyFeedback"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + try { + const supabaseServer = await createSupabaseServer(); + + const { data: authData, error: authError } = + await supabaseServer.auth.getUser(); + + if (authError || !authData.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const requestedParticipantId = body.participantId as string | undefined; + + const authUserId = authData.user.id; + + const { data: profile, error: profileError } = await supabaseAdmin + .from("profiles") + .select("user_id, role") + .eq("user_id", authUserId) + .single(); + + if (profileError || !profile) { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + + let participantId = authUserId; + + if (profile.role === "instructor") { + if (!requestedParticipantId) { + return NextResponse.json( + { error: "participantId is required for instructors" }, + { status: 400 } + ); + } + + const { data: participantProfile } = await supabaseAdmin + .from("profiles") + .select("user_id, instructor_id") + .eq("user_id", requestedParticipantId) + .single(); + + if (!participantProfile || participantProfile.instructor_id !== authUserId) { + return NextResponse.json( + { error: "You can only generate feedback for your own participants" }, + { status: 403 } + ); + } + + participantId = requestedParticipantId; + } + + const result = await generateWeeklyFeedback(supabaseAdmin, participantId); + + return NextResponse.json({ + success: true, + weeklyFeedback: result, + }); + } catch (error: unknown) { + console.error("POST /api/weekly-feedback/generate error:", error); + + const message = + error instanceof Error ? error.message : "Failed to generate weekly feedback"; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index a2dc41e..dc651ec 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,17 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + --od-bg: #F8FAF9; + --od-primary: #009488; + --od-primary-2: #00945A; + --od-border: #B1E0D4; + --od-mint: #C9FBF1; + --od-text: #111827; } +/* ===== Brand Theme ===== */ +@theme { + --color-brand-primary: #1f7a6b; + --color-brand-secondary: #2bb39b; + --color-brand-light: #eaf7f3; + --color-brand-muted: #b4e0d4; +} \ No newline at end of file diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..8f652b9 Binary files /dev/null and b/app/icon.png differ diff --git a/app/instructor/analytics/page.tsx b/app/instructor/analytics/page.tsx new file mode 100644 index 0000000..2ad5c49 --- /dev/null +++ b/app/instructor/analytics/page.tsx @@ -0,0 +1,33 @@ +export const dynamic = "force-dynamic"; + +import InstructorSidebar from "@/components/InstructorSidebar"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { getInstructorAnalytics } from "@/lib/server/instructor/getInstructorAnalytics"; +import InstructorAnalyticsDashboard from "@/components/InstructorAnalyticsDashboard"; + +export default async function InstructorAnalyticsPage() { + const { supabase, instructorId, instructorName } = await requireInstructor(); + + const analytics = await getInstructorAnalytics(supabase, instructorId); + + return ( +
+ + +
+
+
+

+ Analytics Dashboard +

+

+ Track student activity, session completion, and instructor progress. +

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/instructor/assignments/[assignmentId]/edit/page.tsx b/app/instructor/assignments/[assignmentId]/edit/page.tsx new file mode 100644 index 0000000..1470cdb --- /dev/null +++ b/app/instructor/assignments/[assignmentId]/edit/page.tsx @@ -0,0 +1,203 @@ +export const dynamic = "force-dynamic"; + +import Link from "next/link"; +import { ArrowLeft, Pencil } from "lucide-react"; +import InstructorSidebar from "@/components/InstructorSidebar"; +import { requireInstructor } from "@/lib/server/auth/requireInstructor"; +import { getInstructorAssignmentForEdit } from "@/lib/server/instructor/getInstructorAssignmentForEdit"; + +type PageProps = { + params: Promise<{ + assignmentId: string; + }>; +}; + +function formatForDateTimeLocal(dateString: string | null) { + if (!dateString) return ""; + const date = new Date(dateString); + const pad = (n: number) => String(n).padStart(2, "0"); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( + date.getDate() + )}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export default async function EditAssignmentPage({ params }: PageProps) { + const { assignmentId } = await params; + + const { supabase, instructorId, instructorName } = await requireInstructor(); + const { assignment, participants } = await getInstructorAssignmentForEdit( + supabase, + instructorId, + assignmentId + ); + + return ( +
+ + +
+
+
+ + + Back to Assignments + +
+ +
+
+
+ +
+ +
+

+ Edit Assignment +

+

+ Update the assigned session details. +

+
+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ +