From 1bcc1567664e89e370d5a2062994d5c750fc4d5f Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 17 Apr 2026 13:34:07 +0100 Subject: [PATCH 1/2] fix: create-users.ts reads INSTRUCTOR/PARTICIPANT sections from users.md --- scripts/create-users.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/create-users.ts b/scripts/create-users.ts index c8e8846..b5cb2e7 100644 --- a/scripts/create-users.ts +++ b/scripts/create-users.ts @@ -1,5 +1,6 @@ /** - * Bulk-creates participant accounts from docs/users/users.md. + * Bulk-creates users from docs/users/users.md. + * Supports INSTRUCTOR and PARTICIPANT sections — assigns role accordingly. * Safe to re-run — skips existing usernames. * * Usage: npx tsx scripts/create-users.ts @@ -14,25 +15,29 @@ async function run() { const filePath = path.join(process.cwd(), 'docs', 'users', 'users.md'); const content = fs.readFileSync(filePath, 'utf-8'); - // Parse markdown table rows — skip header and separator lines - const rows = content - .split('\n') - .filter((line) => line.startsWith('|') && !line.includes('---') && !line.includes('Username')) - .map((line) => { - const cols = line.split('|').map((c) => c.trim()).filter(Boolean); - return { username: cols[0], password: cols[1] }; - }) - .filter((r) => r.username && r.password); + // Parse sections — role is set by the last INSTRUCTOR/PARTICIPANT heading seen + const users: { username: string; password: string; role: string }[] = []; + let currentRole = 'participant'; - console.log(`Found ${rows.length} users to create...\n`); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed === 'INSTRUCTOR') { currentRole = 'instructor'; continue; } + if (trimmed === 'PARTICIPANT') { currentRole = 'participant'; continue; } + if (!trimmed.startsWith('|') || trimmed.includes('---') || trimmed.toLowerCase().includes('username')) continue; + + const cols = trimmed.split('|').map((c) => c.trim()).filter(Boolean); + if (cols.length >= 2) users.push({ username: cols[0], password: cols[1], role: currentRole }); + } + + console.log(`Found ${users.length} users to process...\n`); let created = 0; let skipped = 0; - for (const { username, password } of rows) { + for (const { username, password, role } of users) { const existing = await sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`; if (existing.length > 0) { - console.log(` Skipped (exists): ${username}`); + console.log(` Skipped (exists): ${username} [${role}]`); skipped++; continue; } @@ -40,9 +45,9 @@ async function run() { const hash = await bcrypt.hash(password, 12); await sql` INSERT INTO users (username, password_hash, role) - VALUES (${username}, ${hash}, 'participant') + VALUES (${username}, ${hash}, ${role}) `; - console.log(` Created: ${username}`); + console.log(` Created: ${username} [${role}]`); created++; } From 62643b9c07e214050bee13fa665502dcd42221ae Mon Sep 17 00:00:00 2001 From: jvcByte Date: Tue, 21 Apr 2026 00:33:23 +0100 Subject: [PATCH 2/2] feat: participant feedback system with structured fields and emoji ratings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add feedback table with rating, challenges, improvements, malfunctions, attachment_url - Create /participant/feedback page with card-based form layout - Add /instructor/feedback view to see all responses - Use emoji icons (Frown→Heart) with color-coded ratings (red→green) - Add Feedback link to participant and instructor navbars - Graceful handling when feedback table doesn't exist yet --- app/api/feedback/route.ts | 31 ++++ app/components/Navbar.tsx | 2 + app/instructor/feedback/page.tsx | 144 +++++++++++++++++ app/participant/feedback/FeedbackForm.tsx | 181 ++++++++++++++++++++++ app/participant/feedback/page.tsx | 29 ++++ migrations/0007_feedback.sql | 15 ++ 6 files changed, 402 insertions(+) create mode 100644 app/api/feedback/route.ts create mode 100644 app/instructor/feedback/page.tsx create mode 100644 app/participant/feedback/FeedbackForm.tsx create mode 100644 app/participant/feedback/page.tsx create mode 100644 migrations/0007_feedback.sql diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts new file mode 100644 index 0000000..fa94cd3 --- /dev/null +++ b/app/api/feedback/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { rating?: number; comments?: string; challenges?: string; improvements?: string; malfunctions?: string; attachment_url?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { rating, comments, challenges, improvements, malfunctions, attachment_url } = body; + + if (rating && (rating < 1 || rating > 5)) { + return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); + } + + await sql` + INSERT INTO feedback (user_id, rating, comments, challenges, improvements, malfunctions, attachment_url) + VALUES (${session.user.id}, ${rating ?? null}, ${comments ?? null}, ${challenges ?? null}, ${improvements ?? null}, ${malfunctions ?? null}, ${attachment_url ?? null}) + `; + + return NextResponse.json({ success: true }); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 456833d..758baeb 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -10,9 +10,11 @@ const defaultLinks: Record = { instructor: [ { href: '/instructor', label: 'Dashboard' }, { href: '/instructor/users', label: 'Users' }, + { href: '/instructor/feedback', label: 'Feedback' }, ], participant: [ { href: '/participant', label: 'Exercises' }, + { href: '/participant/feedback', label: 'Feedback' }, { href: '/participant/settings', label: 'Settings' }, ], }; diff --git a/app/instructor/feedback/page.tsx b/app/instructor/feedback/page.tsx new file mode 100644 index 0000000..9d264c3 --- /dev/null +++ b/app/instructor/feedback/page.tsx @@ -0,0 +1,144 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { sql } from '@/lib/db'; +import Navbar from '@/app/components/Navbar'; +import Link from 'next/link'; + +export default async function FeedbackPage() { + const session = await getServerSession(authOptions); + if (session?.user?.role !== 'instructor') redirect('/login'); + + let feedbackRows: any[] = []; + let tableExists = true; + + try { + feedbackRows = await sql` + SELECT f.id, f.rating, f.comments, f.challenges, f.improvements, f.malfunctions, f.attachment_url, f.submitted_at, u.username + FROM feedback f + INNER JOIN users u ON u.id = f.user_id + ORDER BY f.submitted_at DESC + `; + } catch (err) { + // Table doesn't exist yet — migration not run + if ((err as Error).message.includes('does not exist')) { + tableExists = false; + } else { + throw err; + } + } + + if (!tableExists) { + return ( +
+ +
+
+
+

Participant Feedback

+
+
+

+ Feedback table not found. Run the migration: psql $DATABASE_URL < migrations/0007_feedback.sql +

+
+
+
+
+ ); + } + + const avgRating = feedbackRows.length > 0 + ? (feedbackRows.reduce((sum, f) => sum + (f.rating as number || 0), 0) / feedbackRows.filter(f => f.rating).length).toFixed(1) + : 'N/A'; + + return ( +
+ +
+
+
+ Dashboard + / + Feedback +
+
+

Participant Feedback

+

{feedbackRows.length} responses · Avg rating: {avgRating}

+
+ + {feedbackRows.length === 0 ? ( +
+

No feedback yet.

+
+ ) : ( +
+ {feedbackRows.map((f) => ( +
+
+
+ {f.username as string} + {f.rating && ( + + Rating: {f.rating}/5 + + )} +
+ + {new Date(f.submitted_at as string).toLocaleString()} + +
+ + {f.challenges && ( +
+
Challenges
+

+ {f.challenges as string} +

+
+ )} + + {f.malfunctions && ( +
+
Malfunctions
+

+ {f.malfunctions as string} +

+
+ )} + + {f.improvements && ( +
+
Improvements
+

+ {f.improvements as string} +

+
+ )} + + {f.comments && ( +
+
Additional Comments
+

+ {f.comments as string} +

+
+ )} + + {f.attachment_url && ( +
+
Attachment
+ + View Document → + +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/app/participant/feedback/FeedbackForm.tsx b/app/participant/feedback/FeedbackForm.tsx new file mode 100644 index 0000000..1950873 --- /dev/null +++ b/app/participant/feedback/FeedbackForm.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Frown, Meh, Smile, Laugh, Heart } from 'lucide-react'; + +export default function FeedbackForm() { + const router = useRouter(); + const [rating, setRating] = useState(null); + const [comments, setComments] = useState(''); + const [challenges, setChallenges] = useState(''); + const [improvements, setImprovements] = useState(''); + const [malfunctions, setMalfunctions] = useState(''); + const [attachmentUrl, setAttachmentUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const ratingConfig = [ + { value: 1, icon: Frown, label: 'Poor', color: '#ef4444' }, + { value: 2, icon: Meh, label: 'Fair', color: '#f97316' }, + { value: 3, icon: Smile, label: 'Good', color: '#eab308' }, + { value: 4, icon: Laugh, label: 'Great', color: '#84cc16' }, + { value: 5, icon: Heart, label: 'Excellent', color: '#10b981' }, + ]; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rating, + comments, + challenges, + improvements, + malfunctions, + attachment_url: attachmentUrl || null, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? 'Failed to submit feedback'); + } + + toast.success('Thank you for your feedback!'); + router.push('/participant'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to submit'); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* Rating */} +
+
+ Overall Experience +
+
+ {ratingConfig.map(({ value, icon: Icon, label, color }) => ( + + ))} +
+
+ + {/* Challenges */} +
+
+ Challenges Encountered +
+