Tighten typing and input validation across study pages
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m2s

This commit is contained in:
Elijah 2026-07-13 06:55:17 -07:00
parent 6d1d918355
commit d6f3502cb1
16 changed files with 170 additions and 56 deletions

View file

@ -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);

View file

@ -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<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
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);

View file

@ -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: {

View file

@ -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;

View file

@ -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;
}

View file

@ -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: {

View file

@ -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<string, { currentIndex: number, total: number }> = {};
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 }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item: any) => (
{items.map((item) => (
<div
key={item.id}
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"

View file

@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { QuizViewer } from "@/components/quizzes/QuizViewer";
import { CardList } from "@/components/flashcards/CardList";
import type { SharedStudyItem } from "@/types/study";
interface SharedViewerProps {
type: string;
data: any;
type: "flashcards" | "quizzes";
data: SharedStudyItem;
groupMode?: boolean;
}
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
const [showTopics, setShowTopics] = useState(false);
const categories = type === "quizzes" && data.questions
? Array.from(new Set(data.questions.map((q: any) => 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" ? (
<FlashcardViewer
key={restartKey}
cards={data.cards}
cards={data.cards ?? []}
deckId={data.id}
isShared={true}
/>
) : (
<CardList cards={data.cards} />
<CardList cards={data.cards ?? []} />
)
) : (
<QuizViewer
key={restartKey}
quiz={{
...data,
id: data.id,
name: data.name,
questions: data.questions ?? [],
progress: [], // Start fresh since it's shared
}}
retakeIds={null}

View file

@ -4,6 +4,9 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link";
import { SharedViewer } from "./SharedViewer";
import { SharedGroupViewer } from "./SharedGroupViewer";
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
type SharedContentType = "flashcards" | "quizzes";
interface SharedPageProps {
params: Promise<{
@ -48,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
let targetData: any = null;
let targetType = type;
let targetData: SharedStudyItem | SharedGroupData | null = null;
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
if (type === "groups" && link.group) {
if (itemId) {
// Find the specific item inside the group
if (link.group.type === "DECK") {
targetData = link.group.decks.find(d => 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 (
<div className="min-h-screen bg-bg-base flex flex-col">
{/* Read-only Header */}
@ -105,11 +110,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData} />
<SharedGroupViewer data={targetData as SharedGroupData} />
) : (
<SharedViewer
type={targetType}
data={targetData}
data={targetData as SharedStudyItem}
groupMode={type === "groups"}
/>
)}

View file

@ -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);
}

View file

@ -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<any | null>(null);
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
if (selectedAttempt) {
return (

View file

@ -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<string, string[]>;
}
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
// Aggregate stats per category
const stats: Record<string, { earned: number; possible: number }> = {};
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;

View file

@ -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<string, string[]> = {};
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
<button
onClick={() => {
const allIds = quiz.questions.map((q: any) => q.id).sort(() => Math.random() - 0.5);
const allIds = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
onRetake(allIds);
}}
className="px-6 py-2.5 rounded-lg border border-primary text-primary font-medium hover:bg-primary/5 transition-all duration-200 cursor-pointer"
@ -105,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
{/* Category Breakdown */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
</div>
{/* Review List */}
@ -113,7 +120,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="space-y-6">
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
{reviewItems.map((item, idx) => {
{reviewItems.map((item) => {
const q = item.question;
const sels = item.selections;
return (
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
</div>
<div className="space-y-2 mb-6">
{q.options.map((opt: any) => {
{q.options.map((opt) => {
const isSelected = sels.includes(opt.id);
let stateClass = "cursor-default opacity-80";
let icon = null;

View file

@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
import { scoreQuestion } from "@/lib/scoring";
import { QuizResults } from "./QuizResults";
import { recordStudyActivity } from "@/lib/activityClient";
import type { QuizAttempt } from "@/types/study";
interface Option {
id: string;
@ -48,7 +49,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<any | null>(null);
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// Auto-hide toast

View file

@ -0,0 +1,11 @@
import { z } from "zod";
const reorderItemSchema = z.object({
id: z.string().min(1),
sortOrder: z.number().int(),
groupId: z.string().nullable(),
});
export const reorderRequestSchema = z.object({
items: z.array(reorderItemSchema),
});

65
src/types/study.ts Normal file
View file

@ -0,0 +1,65 @@
export interface ReorderItem {
id: string;
sortOrder: number;
groupId: string | null;
}
export interface QuizOption {
id: string;
text: string;
isCorrect: boolean;
}
export interface QuizQuestion {
id: string;
type: string;
prompt: string;
rationale: string;
category: string;
options: QuizOption[];
}
export interface QuizSummary {
id: string;
name: string;
questions: QuizQuestion[];
}
export interface QuizAttempt {
id: string;
score: number;
maxScore: number;
answersJson: string;
isPartialRetake: boolean;
completedAt: string;
}
export interface SharedClass {
slug: string;
name: string;
}
export interface SharedCard {
id: string;
front: string;
back: string;
sortOrder?: number;
}
export interface SharedStudyItem {
id: string;
name: string;
description: string | null;
class: SharedClass;
cards?: SharedCard[];
questions?: QuizQuestion[];
}
export interface SharedGroupData {
id: string;
name: string;
type: string;
class: SharedClass;
decks: SharedStudyItem[];
quizSets: SharedStudyItem[];
}