From d6f3502cb1013849a9dbaa6e17439fb6a69032dd Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 13 Jul 2026 06:55:17 -0700 Subject: [PATCH] Tighten typing and input validation across study pages --- .../[classSlug]/flashcards/page.tsx | 14 ++-- .../(protected)/[classSlug]/quizzes/page.tsx | 14 ++-- src/app/api/decks/reorder/route.ts | 11 ++-- src/app/api/material-groups/[id]/route.ts | 3 +- src/app/api/material-groups/route.ts | 3 +- src/app/api/quizzes/reorder/route.ts | 11 ++-- .../[type]/[token]/SharedGroupViewer.tsx | 7 +- .../[type]/[token]/SharedViewer.tsx | 15 +++-- .../[classSlug]/[type]/[token]/page.tsx | 17 +++-- src/components/import/CreateTab.tsx | 4 +- src/components/quizzes/AttemptHistory.tsx | 7 +- src/components/quizzes/CategoryBreakdown.tsx | 14 ++-- src/components/quizzes/QuizResults.tsx | 27 +++++--- src/components/quizzes/QuizViewer.tsx | 3 +- src/lib/validation/reorderSchemas.ts | 11 ++++ src/types/study.ts | 65 +++++++++++++++++++ 16 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 src/lib/validation/reorderSchemas.ts create mode 100644 src/types/study.ts diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index 5bd7ab7..8a455f1 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -27,6 +27,7 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import type { ReorderItem } from "@/types/study"; interface DeckItem { id: string; @@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) { return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; } -function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) { +interface SortableDeckCardProps { + deck: DeckItem; + onEdit: (deck: DeckItem) => void; + onDelete: (deck: DeckItem) => void; + classSlug: string; +} + +function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), @@ -314,8 +322,6 @@ export default function FlashcardsPage() { if (!activeDeck) return; let targetGroupId: string | null = null; - let targetIndex = 0; - const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { targetGroupId = overContainerId === "uncategorized" ? null : overContainerId; @@ -344,7 +350,7 @@ export default function FlashcardsPage() { } const affectedGroups = new Set([activeDeck.groupId, targetGroupId]); - const updates: any[] = []; + const updates: ReorderItem[] = []; affectedGroups.forEach(gId => { const gItems = newItems.filter(d => d.groupId === gId); diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 7a09211..97be050 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -27,6 +27,7 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import type { ReorderItem } from "@/types/study"; interface QuizItem { id: string; @@ -51,7 +52,14 @@ interface MaterialGroup { const cache: Record = {}; -function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) { +interface SortableQuizCardProps { + quiz: QuizItem; + onEdit: (quiz: QuizItem) => void; + onDelete: (quiz: QuizItem) => void; + classSlug: string; +} + +function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const style = { transform: CSS.Transform.toString(transform), @@ -312,8 +320,6 @@ export default function QuizzesPage() { if (!activeQuiz) return; let targetGroupId: string | null = null; - let targetIndex = 0; - // Check if over a group container directly const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { @@ -345,7 +351,7 @@ export default function QuizzesPage() { // Re-calculate sortOrder for the affected groups to persist const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]); - const updates: any[] = []; + const updates: ReorderItem[] = []; affectedGroups.forEach(gId => { const gItems = newItems.filter(q => q.groupId === gId); diff --git a/src/app/api/decks/reorder/route.ts b/src/app/api/decks/reorder/route.ts index 97313be..c7bcc43 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -1,19 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; export async function PATCH(request: NextRequest) { try { - const body = await request.json(); - const { items } = body; - - if (!Array.isArray(items)) { + const parsed = reorderRequestSchema.safeParse(await request.json()); + if (!parsed.success) { return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } + const { items } = parsed.data; + // items should be an array of { id, sortOrder, groupId } // Using a transaction to perform all updates await prisma.$transaction( - items.map((item: any) => + items.map((item) => prisma.deck.update({ where: { id: item.id }, data: { diff --git a/src/app/api/material-groups/[id]/route.ts b/src/app/api/material-groups/[id]/route.ts index f2e0316..bbd03cf 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; export async function PATCH( request: NextRequest, @@ -10,7 +11,7 @@ export async function PATCH( const body = await request.json(); const { name, sortOrder } = body; - const updateData: any = {}; + const updateData: Prisma.MaterialGroupUpdateInput = {}; if (name !== undefined) updateData.name = name; if (sortOrder !== undefined) updateData.sortOrder = sortOrder; diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 974b8ee..6184603 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -14,7 +15,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Invalid type" }, { status: 400 }); } - const whereClause: any = { classId }; + const whereClause: Prisma.MaterialGroupWhereInput = { classId }; if (type) { whereClause.type = type; } diff --git a/src/app/api/quizzes/reorder/route.ts b/src/app/api/quizzes/reorder/route.ts index cc6d499..7ad7087 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -1,19 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; export async function PATCH(request: NextRequest) { try { - const body = await request.json(); - const { items } = body; - - if (!Array.isArray(items)) { + const parsed = reorderRequestSchema.safeParse(await request.json()); + if (!parsed.success) { return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } + const { items } = parsed.data; + // items should be an array of { id, sortOrder, groupId } // Using a transaction to perform all updates await prisma.$transaction( - items.map((item: any) => + items.map((item) => prisma.quizSet.update({ where: { id: item.id }, data: { diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx index a49d137..5381446 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx @@ -4,8 +4,9 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import type { SharedGroupData } from "@/types/study"; -export function SharedGroupViewer({ data }: { data: any }) { +export function SharedGroupViewer({ data }: { data: SharedGroupData }) { const pathname = usePathname(); const router = useRouter(); @@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) { if (typeof window === 'undefined') return; const newProgress: Record = {}; - items.forEach((item: any) => { + items.forEach((item) => { const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; const saved = localStorage.getItem(key); if (saved) { @@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
- {items.map((item: any) => ( + {items.map((item) => (
q.category.toUpperCase()))).join(" · ") + ? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ") : ""; return ( @@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp view === "study" ? ( ) : ( - + ) ) : ( d.id === itemId); + targetData = link.group.decks.find(d => d.id === itemId) ?? null; targetType = "flashcards"; } else { - targetData = link.group.quizSets.find(q => q.id === itemId); + targetData = link.group.quizSets.find(q => q.id === itemId) ?? null; targetType = "quizzes"; } if (!targetData) notFound(); @@ -69,6 +72,8 @@ export default async function SharedPage(props: SharedPageProps & { searchParams targetData = type === "flashcards" ? link.deck : link.quizSet; } + if (!targetData) notFound(); + return (
{/* Read-only Header */} @@ -105,11 +110,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{type === "groups" && !itemId ? ( - + ) : ( )} diff --git a/src/components/import/CreateTab.tsx b/src/components/import/CreateTab.tsx index 37fd693..6867575 100644 --- a/src/components/import/CreateTab.tsx +++ b/src/components/import/CreateTab.tsx @@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) { } onCreated(); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to create deck"); } finally { setSaving(false); } diff --git a/src/components/quizzes/AttemptHistory.tsx b/src/components/quizzes/AttemptHistory.tsx index a0b7892..6ee69d3 100644 --- a/src/components/quizzes/AttemptHistory.tsx +++ b/src/components/quizzes/AttemptHistory.tsx @@ -2,15 +2,16 @@ import { useState } from "react"; import { QuizResults } from "./QuizResults"; +import type { QuizAttempt, QuizSummary } from "@/types/study"; interface AttemptHistoryProps { - quiz: any; - attempts: any[]; + quiz: QuizSummary; + attempts: QuizAttempt[]; onRetake: (missedIds: string[]) => void; } export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) { - const [selectedAttempt, setSelectedAttempt] = useState(null); + const [selectedAttempt, setSelectedAttempt] = useState(null); if (selectedAttempt) { return ( diff --git a/src/components/quizzes/CategoryBreakdown.tsx b/src/components/quizzes/CategoryBreakdown.tsx index 6f16b22..e8956a9 100644 --- a/src/components/quizzes/CategoryBreakdown.tsx +++ b/src/components/quizzes/CategoryBreakdown.tsx @@ -2,20 +2,20 @@ import { Fragment } from "react"; import { scoreQuestion } from "@/lib/scoring"; +import type { QuizSummary } from "@/types/study"; interface CategoryBreakdownProps { - quiz: any; - attempt: any; + quiz: QuizSummary; answeredIds: string[]; answers: Record; } -export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) { +export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) { // Aggregate stats per category const stats: Record = {}; answeredIds.forEach((qId) => { - const q = quiz.questions.find((x: any) => x.id === qId); + const q = quiz.questions.find((x) => x.id === qId); if (!q) return; const cat = q.category.trim().toLowerCase(); @@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ stats[cat] = { earned: 0, possible: 0 }; } - const formattedQ = { id: q.id, type: q.type, options: q.options }; + const formattedQ = { + id: q.id, + type: q.type as "MULTIPLE_CHOICE" | "SATA", + options: q.options, + }; const points = scoreQuestion(formattedQ, answers[q.id] || []); stats[cat].earned += points; diff --git a/src/components/quizzes/QuizResults.tsx b/src/components/quizzes/QuizResults.tsx index a66e7e8..95f0baa 100644 --- a/src/components/quizzes/QuizResults.tsx +++ b/src/components/quizzes/QuizResults.tsx @@ -4,14 +4,21 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { CategoryBreakdown } from "./CategoryBreakdown"; import { scoreQuestion } from "@/lib/scoring"; +import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study"; interface QuizResultsProps { - quiz: any; // QuizData - attempt: any; // QuizAttempt + quiz: QuizSummary; + attempt: QuizAttempt; onRetake: (missedIds: string[]) => void; onClose?: () => void; } +interface ReviewItem { + question: QuizQuestion; + score: number; + selections: string[]; +} + export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) { let answers: Record = {}; try { @@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro } catch {} // Determine non-full-credit questions for review list - const reviewItems: any[] = []; + const reviewItems: ReviewItem[] = []; const missedIds: string[] = []; const answeredIds = Object.keys(answers); - const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id)); + const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id)); - questionsToReview.forEach((q: any) => { + questionsToReview.forEach((q) => { const formattedQ = { id: q.id, - type: q.type, + type: q.type as "MULTIPLE_CHOICE" | "SATA", options: q.options, }; const score = scoreQuestion(formattedQ, answers[q.id] || []); @@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro