Refactor Study Desk application structure

This commit is contained in:
Elijah 2026-08-07 19:31:23 -07:00
parent faaccf8a7e
commit 089439ed90
145 changed files with 8087 additions and 3412 deletions

View file

@ -1,5 +1,9 @@
import { getIronSession, type SessionOptions } from "iron-session";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import {
getPasswordResetSessionOptions,
getSessionOptions,
} from "@/lib/sessionConfig";
export interface SessionData {
isAuthenticated: boolean;
@ -11,31 +15,9 @@ interface PasswordResetSessionData {
expiresAt?: string;
}
const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET || "dev-session-secret-change-in-production-must-be-32-chars",
cookieName: "study-app-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
httpOnly: true,
sameSite: "lax" as const,
},
};
const passwordResetSessionOptions: SessionOptions = {
password: sessionOptions.password,
cookieName: "study-app-password-reset",
cookieOptions: {
secure: sessionOptions.cookieOptions?.secure,
httpOnly: true,
sameSite: "lax" as const,
maxAge: 15 * 60,
path: "/",
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
return getIronSession<SessionData>(cookieStore, getSessionOptions());
}
export async function createSession(sessionGeneration: number) {
@ -59,7 +41,7 @@ export async function getPasswordResetSession() {
const cookieStore = await cookies();
return getIronSession<PasswordResetSessionData>(
cookieStore,
passwordResetSessionOptions
getPasswordResetSessionOptions()
);
}

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { readLimitedJson, RequestTooLargeError } from "./limitedJson";
describe("readLimitedJson", () => {
it("rejects declared and measured oversized request bodies", async () => {
const declared = new NextRequest("http://localhost/api/decks", {
method: "POST",
headers: { "content-length": "100" },
body: "{}",
});
await expect(readLimitedJson(declared, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
const measured = new NextRequest("http://localhost/api/decks", {
method: "POST",
body: JSON.stringify({ value: "oversized" }),
});
await expect(readLimitedJson(measured, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
});
});

24
src/lib/limitedJson.ts Normal file
View file

@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { CONTENT_LIMITS } from "@/lib/validation/contentSchemas";
export class RequestTooLargeError extends Error {}
export async function readLimitedJson(
request: NextRequest,
maximumBytes = CONTENT_LIMITS.requestBytes
): Promise<unknown> {
const declaredLength = Number(request.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
}
const body = await request.text();
if (new TextEncoder().encode(body).byteLength > maximumBytes) {
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
}
try {
return JSON.parse(body) as unknown;
} catch {
return null;
}
}

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { normalizeProgress } from "./progressNormalization";
const isGrade = (value: unknown): value is "correct" | "missed" =>
value === "correct" || value === "missed";
function normalize(order: string[], currentIndex: number, liveIds: string[]) {
return normalizeProgress({
orderJson: JSON.stringify(order),
currentIndex,
dataJson: JSON.stringify({ a: "correct", deleted: "missed" }),
liveIds,
isValidValue: isGrade,
});
}
describe("normalizeProgress", () => {
it("preserves the logical current card when a prior card was deleted", () => {
expect(normalize(["a", "b", "c"], 1, ["b", "c"]).currentIndex).toBe(0);
});
it("uses the next survivor when the current card was deleted", () => {
expect(normalize(["a", "b", "c"], 1, ["a", "c"]).currentIndex).toBe(1);
});
it("uses the previous survivor when no later card remains", () => {
expect(normalize(["a", "b", "c"], 2, ["a", "b"]).currentIndex).toBe(1);
});
it("preserves completed state after stale IDs are removed", () => {
const result = normalize(["a", "b", "c"], 3, ["a", "c"]);
expect(result.currentIndex).toBe(2);
expect(result.completed).toBe(true);
});
it("recovers malformed JSON and filters stale result keys", () => {
const result = normalizeProgress({
orderJson: "not-json",
currentIndex: -1,
dataJson: '{"a":"correct","b":"invalid"}',
liveIds: ["a", "b"],
isValidValue: isGrade,
});
expect(result).toMatchObject({
order: ["a", "b"],
currentIndex: 0,
data: { a: "correct" },
completed: false,
wasRecovered: true,
});
});
it("returns a clear completed state when all saved cards were deleted", () => {
const result = normalize(["deleted"], 0, []);
expect(result).toMatchObject({ order: [], currentIndex: 0, completed: true });
});
it("lets content-specific validation reject a value owned by another item", () => {
const result = normalizeProgress({
orderJson: '["q1","q2"]',
currentIndex: 0,
dataJson: '{"q1":["q2-option"]}',
liveIds: ["q1", "q2"],
isValidValue: (value, id): value is string[] =>
Array.isArray(value) && value.every((optionId) => optionId === `${id}-option`),
});
expect(result.data).toEqual({});
expect(result.wasRecovered).toBe(true);
});
});

View file

@ -0,0 +1,108 @@
type JsonRecord = Record<string, unknown>;
interface NormalizeProgressInput<T> {
orderJson: unknown;
currentIndex: unknown;
dataJson?: unknown;
liveIds: string[];
isValidValue: (value: unknown, id: string) => value is T;
}
export interface NormalizedProgress<T> {
order: string[];
currentIndex: number;
data: Record<string, T>;
completed: boolean;
wasRecovered: boolean;
}
function parseJson(value: unknown): { value: unknown; invalid: boolean } {
if (typeof value !== "string") return { value, invalid: false };
try {
return { value: JSON.parse(value), invalid: false };
} catch {
return { value: undefined, invalid: true };
}
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function normalizeProgress<T>({
orderJson,
currentIndex,
dataJson,
liveIds,
isValidValue,
}: NormalizeProgressInput<T>): NormalizedProgress<T> {
const uniqueLiveIds = [...new Set(liveIds)];
const liveIdSet = new Set(uniqueLiveIds);
const parsedOrder = parseJson(orderJson);
const hasValidSavedOrder =
Array.isArray(parsedOrder.value) &&
parsedOrder.value.every((id) => typeof id === "string");
const savedOrder = hasValidSavedOrder
? (parsedOrder.value as string[])
: uniqueLiveIds;
let wasRecovered = parsedOrder.invalid || !hasValidSavedOrder;
const seen = new Set<string>();
const order = savedOrder.filter((id) => {
const keep = liveIdSet.has(id) && !seen.has(id);
if (!keep) wasRecovered = true;
seen.add(id);
return keep;
});
const savedIndex =
typeof currentIndex === "number" &&
Number.isSafeInteger(currentIndex) &&
currentIndex >= 0
? currentIndex
: 0;
if (savedIndex !== currentIndex) wasRecovered = true;
const savedCompleted =
hasValidSavedOrder && savedOrder.length > 0 && savedIndex >= savedOrder.length;
let normalizedIndex = 0;
if (order.length === 0 || savedCompleted) {
normalizedIndex = order.length;
} else {
const currentId = savedOrder[savedIndex];
if (currentId && order.includes(currentId)) {
normalizedIndex = order.indexOf(currentId);
} else {
const nextId = savedOrder
.slice(savedIndex + 1)
.find((id) => order.includes(id));
const previousId = savedOrder
.slice(0, savedIndex)
.reverse()
.find((id) => order.includes(id));
const replacementId = nextId ?? previousId;
normalizedIndex = replacementId ? order.indexOf(replacementId) : 0;
if (currentId !== order[normalizedIndex]) wasRecovered = true;
}
}
const parsedData = parseJson(dataJson ?? {});
const rawData = isRecord(parsedData.value) ? parsedData.value : {};
if (parsedData.invalid || !isRecord(parsedData.value)) wasRecovered = true;
const data: Record<string, T> = {};
for (const [id, value] of Object.entries(rawData)) {
if (liveIdSet.has(id) && isValidValue(value, id)) {
data[id] = value;
} else {
wasRecovered = true;
}
}
return {
order,
currentIndex: normalizedIndex,
data,
completed: order.length === 0 || normalizedIndex === order.length,
wasRecovered,
};
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { isPublicPath } from "./publicPaths";
describe("isPublicPath", () => {
it.each([
"/login",
"/api/auth/login",
"/api/health",
"/shared/class/quizzes/token",
"/_next/static/chunk.js",
"/favicon.ico",
])("allows intentional public path %s", (pathname) => {
expect(isPublicPath(pathname)).toBe(true);
});
it.each([
"/private/file.txt",
"/api/decks/file.json",
"/api/authentication/fake",
"/shared-secret",
"/private/%2e%2e/data",
])("does not bypass authentication for %s", (pathname) => {
expect(isPublicPath(pathname)).toBe(false);
});
});

17
src/lib/publicPaths.ts Normal file
View file

@ -0,0 +1,17 @@
const EXACT_PUBLIC_PATHS = new Set([
"/login",
"/api/health",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
"/icon.svg",
]);
export function isPublicPath(pathname: string) {
return (
EXACT_PUBLIC_PATHS.has(pathname) ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/shared/") ||
pathname.startsWith("/api/auth/")
);
}

33
src/lib/quizSnapshots.ts Normal file
View file

@ -0,0 +1,33 @@
import type { QuizQuestion } from "@/types/study";
export interface SnapshotQuestion extends QuizQuestion {
selections: string[];
points: number;
}
export interface QuizReviewSnapshot {
version: 1;
questions: SnapshotQuestion[];
}
export function parseQuizReviewSnapshot(
value: string | null | undefined
): QuizReviewSnapshot | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
if (
typeof parsed !== "object" ||
parsed === null ||
!("version" in parsed) ||
parsed.version !== 1 ||
!("questions" in parsed) ||
!Array.isArray(parsed.questions)
) {
return null;
}
return parsed as QuizReviewSnapshot;
} catch {
return null;
}
}

View file

@ -0,0 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkRateLimit, clearRateLimits } from "./rateLimiter";
describe("checkRateLimit", () => {
beforeEach(() => {
clearRateLimits();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-07T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("blocks after the configured global request limit", () => {
expect(checkRateLimit("reset", { maxRequests: 1 }).allowed).toBe(true);
expect(checkRateLimit("reset", { maxRequests: 1 })).toMatchObject({
allowed: false,
retryAfterMs: 60_000,
});
});
it("allows a request after the window expires", () => {
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 });
vi.advanceTimersByTime(1_000);
expect(
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 }).allowed
).toBe(true);
});
});

View file

@ -12,6 +12,10 @@ const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10;
export function clearRateLimits() {
store.clear();
}
export function checkRateLimit(
key: string,
options: { windowMs?: number; maxRequests?: number } = {}

48
src/lib/scoring.test.ts Normal file
View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { scoreQuestion, scoreQuiz } from "./scoring";
describe("quiz scoring", () => {
it("returns zero rather than NaN for invalid zero-correct SATA data", () => {
expect(
scoreQuestion(
{
id: "q1",
type: "SATA",
options: [{ id: "o1", isCorrect: false }],
},
["o1"]
)
).toBe(0);
});
it("does not award duplicate SATA selections more than once", () => {
expect(
scoreQuestion(
{
id: "q1",
type: "SATA",
options: [
{ id: "o1", isCorrect: true },
{ id: "o2", isCorrect: true },
],
},
["o1", "o1"]
)
).toBe(0.5);
});
it("keeps totals finite when legacy questions are malformed", () => {
const result = scoreQuiz(
[
{
id: "q1",
type: "SATA",
options: [{ id: "o1", isCorrect: false }],
},
],
{ q1: [] }
);
expect(Number.isFinite(result.total)).toBe(true);
expect(result).toMatchObject({ total: 0, maxScore: 1 });
});
});

View file

@ -9,19 +9,22 @@ export function scoreQuestion(
question: QuestionLite,
selectedIds: string[]
): number {
const uniqueSelectedIds = [...new Set(selectedIds)];
const correctIds = question.options
.filter((o) => o.isCorrect)
.map((o) => o.id);
if (question.type === "MULTIPLE_CHOICE") {
return correctIds.includes(selectedIds[0]) ? 1 : 0;
return correctIds.includes(uniqueSelectedIds[0]) ? 1 : 0;
}
if (correctIds.length === 0) return 0;
// SATA: partial credit
const correctSelected = selectedIds.filter((id) =>
const correctSelected = uniqueSelectedIds.filter((id) =>
correctIds.includes(id)
).length;
const incorrectSelected = selectedIds.filter(
const incorrectSelected = uniqueSelectedIds.filter(
(id) => !correctIds.includes(id)
).length;
return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { sealData, unsealData } from "iron-session";
import {
DEVELOPMENT_SESSION_SECRET,
getSessionOptions,
} from "./sessionConfig";
describe("session configuration", () => {
it.each([undefined, "", "short", DEVELOPMENT_SESSION_SECRET])(
"rejects invalid production secret %s",
(secret) => {
expect(() =>
getSessionOptions({ NODE_ENV: "production", SESSION_SECRET: secret })
).toThrow(/SESSION_SECRET/);
}
);
it("allows the labelled fallback only outside production", () => {
expect(getSessionOptions({ NODE_ENV: "development" }).password).toBe(
DEVELOPMENT_SESSION_SECRET
);
});
it("honors secure cookies only when explicitly enabled in production", () => {
const options = getSessionOptions({
NODE_ENV: "production",
SESSION_SECRET: "a-secure-production-secret-that-is-long-enough",
SECURE_COOKIES: "true",
});
expect(options.cookieOptions?.secure).toBe(true);
});
it("does not authenticate data sealed with the development fallback under a real secret", async () => {
const sealed = await sealData(
{ isAuthenticated: true },
{ password: DEVELOPMENT_SESSION_SECRET, ttl: 60 }
);
await expect(unsealData(sealed, {
password: "a-secure-production-secret-that-is-long-enough",
ttl: 60,
})).resolves.toEqual({});
});
});

58
src/lib/sessionConfig.ts Normal file
View file

@ -0,0 +1,58 @@
import type { SessionOptions } from "iron-session";
export const DEVELOPMENT_SESSION_SECRET =
"dev-session-secret-change-in-production-must-be-32-chars";
function sessionPassword(env: NodeJS.ProcessEnv) {
const configured = env.SESSION_SECRET?.trim();
if (env.NODE_ENV === "production") {
if (
!configured ||
configured.length < 32 ||
configured === DEVELOPMENT_SESSION_SECRET
) {
throw new Error(
"SESSION_SECRET must be a non-default value of at least 32 characters in production"
);
}
return configured;
}
if (!configured) return DEVELOPMENT_SESSION_SECRET;
if (configured.length < 32) {
throw new Error("SESSION_SECRET must be at least 32 characters");
}
return configured;
}
export function getSessionOptions(
env: NodeJS.ProcessEnv = process.env
): SessionOptions {
return {
password: sessionPassword(env),
cookieName: "study-app-session",
cookieOptions: {
secure: env.NODE_ENV === "production" && env.SECURE_COOKIES === "true",
httpOnly: true,
sameSite: "lax",
path: "/",
},
};
}
export function getPasswordResetSessionOptions(
env: NodeJS.ProcessEnv = process.env
): SessionOptions {
const sessionOptions = getSessionOptions(env);
return {
password: sessionOptions.password,
cookieName: "study-app-password-reset",
cookieOptions: {
secure: sessionOptions.cookieOptions?.secure,
httpOnly: true,
sameSite: "lax",
maxAge: 15 * 60,
path: "/",
},
};
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildShareMetadata, type ShareMetaLink } from "./shareMetadata";
describe("buildShareMetadata", () => {
const deckLink: ShareMetaLink = {
targetType: "DECK",
deck: {
name: "Deck",
description: null,
class: { slug: "class", name: "Class" },
_count: { cards: 3 },
},
quizSet: null,
group: null,
};
it("does not describe a token under a mismatched shared route type", () => {
expect(
buildShareMetadata(deckLink, {
classSlug: "class",
pathType: "quizzes",
}).title
).toBe("Study Desk");
});
it("describes the target only when token, class, and route type agree", () => {
expect(
buildShareMetadata(deckLink, {
classSlug: "class",
pathType: "flashcards",
}).title
).toContain("Deck");
});
});

View file

@ -22,6 +22,7 @@ interface MetaItem {
}
export interface ShareMetaLink {
targetType: string;
deck: {
name: string;
description: string | null;
@ -38,8 +39,8 @@ export interface ShareMetaLink {
name: string;
type: string;
class: MetaClass;
decks: { id: string; name: string; description: string | null; _count: { cards: number } }[];
quizSets: { id: string; name: string; description: string | null; _count: { questions: number } }[];
decks: { id: string; name: string; description: string | null; class: MetaClass; _count: { cards: number } }[];
quizSets: { id: string; name: string; description: string | null; class: MetaClass; _count: { questions: number } }[];
} | null;
}
@ -95,16 +96,30 @@ function itemMetadata(
*/
export function buildShareMetadata(
link: ShareMetaLink | null,
options: { classSlug?: string; itemId?: string } = {},
options: {
classSlug?: string;
itemId?: string;
pathType?: "flashcards" | "quizzes" | "groups";
} = {},
): Metadata {
const { classSlug, itemId } = options;
const { classSlug, itemId, pathType } = options;
if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION);
// Never describe content whose URL does not agree with the stored class, so
// previews stay silent for the same requests the page itself rejects.
const targets = [link.deck, link.quizSet, link.group].filter(Boolean);
const target = link.deck ?? link.quizSet ?? link.group;
if (!target || (classSlug && target.class.slug !== classSlug)) {
const targetAgrees =
(link.targetType === "DECK" && Boolean(link.deck) && pathType !== "quizzes" && pathType !== "groups") ||
(link.targetType === "QUIZ" && Boolean(link.quizSet) && pathType !== "flashcards" && pathType !== "groups") ||
(link.targetType === "GROUP" && Boolean(link.group) && pathType !== "flashcards" && pathType !== "quizzes");
if (
!target ||
targets.length !== 1 ||
!targetAgrees ||
(classSlug && target.class.slug !== classSlug)
) {
return toMetadata(SITE_NAME, SITE_DESCRIPTION);
}
@ -129,8 +144,12 @@ export function buildShareMetadata(
const isDeckGroup = group.type === "DECK";
if (itemId) {
const deck = isDeckGroup ? group.decks.find((d) => d.id === itemId) : undefined;
const quizSet = isDeckGroup ? undefined : group.quizSets.find((q) => q.id === itemId);
const deck = isDeckGroup
? group.decks.find((d) => d.id === itemId && d.class.slug === group.class.slug)
: undefined;
const quizSet = isDeckGroup
? undefined
: group.quizSets.find((q) => q.id === itemId && q.class.slug === group.class.slug);
if (deck) {
return itemMetadata(

View file

@ -14,12 +14,18 @@ describe("spaced repetition scheduling", () => {
it("returns a distinct valid state for every rating", () => {
const ratings: SpacedRepetitionRating[] = ["AGAIN", "HARD", "GOOD", "EASY"];
const dueTimes: number[] = [];
for (const rating of ratings) {
const state = scheduleRating(null, rating, now);
expect(state.reps).toBe(1);
expect(state.lastReview?.toISOString()).toBe(now.toISOString());
expect(state.due.getTime()).toBeGreaterThan(now.getTime());
dueTimes.push(state.due.getTime());
}
expect(new Set(dueTimes).size).toBe(4);
expect(dueTimes[0]).toBeLessThan(dueTimes[1]);
expect(dueTimes[1]).toBeLessThan(dueTimes[2]);
expect(dueTimes[2]).toBeLessThan(dueTimes[3]);
});
it("orders first-review previews from Again through Easy", () => {
@ -49,6 +55,16 @@ describe("spaced repetition scheduling", () => {
expect(graduated.due.toISOString()).toMatch(/T07:00:00\.000Z$/);
});
it("records a lapse and returns a graduated card to a learning step", () => {
const learning = scheduleRating(null, "GOOD", now);
const graduated = scheduleRating(learning, "GOOD", learning.due);
const lapsed = scheduleRating(graduated, "AGAIN", graduated.due);
expect(lapsed.lapses).toBe(graduated.lapses + 1);
expect(lapsed.scheduledDays).toBe(0);
expect(lapsed.due.getTime()).toBeGreaterThan(graduated.due.getTime());
});
it("uses Anki's 20-minute learn-ahead window", () => {
expect(getLearnAheadCutoff(now).toISOString()).toBe("2026-07-13T20:20:00.000Z");
});

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { quizAttemptSchema } from "./attemptSchemas";
describe("quizAttemptSchema", () => {
it("accepts structured scoped answers", () => {
expect(
quizAttemptSchema.safeParse({
questionIds: ["q1", "q2"],
answers: { q1: ["o1"], q2: [] },
}).success
).toBe(true);
});
it.each([
{
questionIds: ["q1", "q1"],
answers: {},
},
{
questionIds: ["q1"],
answers: { q1: ["o1", "o1"] },
},
{
questionIds: ["q1"],
answers: { q1: ["o1"] },
isPartialRetake: true,
},
{
questionIds: "q1",
answers: "not-an-object",
},
])("rejects an invalid or client-forged envelope", (input) => {
expect(quizAttemptSchema.safeParse(input).success).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
import { z } from "zod";
const selectedOptionIdsSchema = z
.array(z.string().min(1).max(100))
.max(100)
.refine((ids) => new Set(ids).size === ids.length, {
message: "Selected option IDs must be unique",
});
export const quizAttemptSchema = z
.object({
questionIds: z
.array(z.string().min(1).max(100))
.min(1)
.max(1_000)
.refine((ids) => new Set(ids).size === ids.length, {
message: "Question IDs must be unique",
}),
answers: z.record(z.string(), selectedOptionIdsSchema),
})
.strict();
export type QuizAttemptInput = z.infer<typeof quizAttemptSchema>;

View file

@ -0,0 +1,43 @@
import { z } from "zod";
export const CONTENT_LIMITS = {
name: 160,
description: 4_000,
markdown: 32_000,
category: 160,
option: 8_000,
cards: 2_000,
questions: 1_000,
options: 20,
requestBytes: 2 * 1024 * 1024,
} as const;
export const idSchema = z.string().trim().min(1).max(191);
export const nameSchema = z.string().trim().min(1).max(CONTENT_LIMITS.name);
export const descriptionSchema = z.string().trim().max(CONTENT_LIMITS.description);
export const markdownSchema = z.string().trim().min(1).max(CONTENT_LIMITS.markdown);
export const cardContentSchema = z.object({
front: markdownSchema,
back: markdownSchema,
}).strict();
export const cardUpdateSchema = cardContentSchema.partial().refine(
(value) => value.front !== undefined || value.back !== undefined,
"At least one card field is required"
);
export const contentUpdateSchema = z.object({
name: nameSchema.optional(),
description: descriptionSchema.nullable().optional(),
}).strict().refine(
(value) => value.name !== undefined || value.description !== undefined,
"At least one field is required"
);
export const classCreateSchema = z.object({ name: nameSchema }).strict();
export const classUpdateSchema = classCreateSchema;
export function isPrismaError(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
deckImportRequestSchema,
flashcardImportSchema,
quizImportSchema,
} from "./importSchemas";
import { CONTENT_LIMITS } from "./contentSchemas";
const quizBase = {
type: "quiz" as const,
quizName: "Quiz",
questions: [
{
type: "sata" as const,
prompt: "Prompt",
rationale: "Rationale",
category: "Category",
options: [
{ text: "A", correct: true },
{ text: "B", correct: false },
],
},
],
};
describe("import schemas", () => {
it("requires at least two correct SATA options", () => {
const result = quizImportSchema.safeParse(quizBase);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.message.includes("at least two"))).toBe(true);
}
});
it("accepts SATA with two correct options", () => {
const input = structuredClone(quizBase);
input.questions[0].options[1].correct = true;
expect(quizImportSchema.safeParse(input).success).toBe(true);
});
it("requires exactly one correct multiple-choice option", () => {
const input = {
...structuredClone(quizBase),
questions: [
{
...structuredClone(quizBase.questions[0]),
type: "multiple_choice" as const,
},
],
};
input.questions[0].options[1].correct = true;
expect(quizImportSchema.safeParse(input).success).toBe(false);
});
it("rejects incomplete flashcards", () => {
expect(
flashcardImportSchema.safeParse({
type: "flashcards",
deckName: "Deck",
cards: [{ front: "Question", back: "" }],
}).success
).toBe(false);
});
it("trims before rejecting whitespace-only content", () => {
expect(
flashcardImportSchema.safeParse({
type: "flashcards",
deckName: " ",
cards: [{ front: "Question", back: "Answer" }],
}).success
).toBe(false);
});
it("rejects oversized markdown and incomplete request envelopes", () => {
const data = {
type: "flashcards" as const,
deckName: "Deck",
cards: [{ front: "x".repeat(CONTENT_LIMITS.markdown + 1), back: "Answer" }],
};
expect(flashcardImportSchema.safeParse(data).success).toBe(false);
expect(deckImportRequestSchema.safeParse({ data }).success).toBe(false);
});
});

View file

@ -1,48 +1,67 @@
import { z } from "zod";
import {
CONTENT_LIMITS,
cardContentSchema,
descriptionSchema,
idSchema,
nameSchema,
} from "@/lib/validation/contentSchemas";
const optionSchema = z.object({
text: z.string().min(1),
text: z.string().trim().min(1).max(CONTENT_LIMITS.option),
correct: z.boolean(),
});
}).strict();
const questionSchema = z
.object({
type: z.enum(["multiple_choice", "sata"]),
prompt: z.string().min(1),
rationale: z.string().min(1),
category: z.string().min(1),
options: z.array(optionSchema).min(2),
})
.refine((q) => q.options.filter((o) => o.correct).length >= 1, {
const questionSchema = z.object({
type: z.enum(["multiple_choice", "sata"]),
prompt: z.string().trim().min(1).max(CONTENT_LIMITS.markdown),
rationale: z.string().trim().min(1).max(CONTENT_LIMITS.markdown),
category: z.string().trim().min(1).max(CONTENT_LIMITS.category),
options: z.array(optionSchema).min(2).max(CONTENT_LIMITS.options),
}).strict()
.refine((question) => question.options.some((option) => option.correct), {
message: "Each question needs at least one correct option",
})
.refine(
(q) =>
q.type !== "multiple_choice" ||
q.options.filter((o) => o.correct).length === 1,
(question) => question.type !== "multiple_choice" ||
question.options.filter((option) => option.correct).length === 1,
{ message: "multiple_choice questions must have exactly one correct option" }
)
.refine(
(question) => question.type !== "sata" ||
question.options.filter((option) => option.correct).length >= 2,
{ message: "sata questions must have at least two correct options" }
);
export const quizImportSchema = z.object({
type: z.literal("quiz"),
quizName: z.string().min(1),
description: z.string().optional(),
questions: z.array(questionSchema).min(1),
});
quizName: nameSchema,
description: descriptionSchema.optional(),
questions: z.array(questionSchema).min(1).max(CONTENT_LIMITS.questions),
}).strict();
export const flashcardImportSchema = z.object({
type: z.literal("flashcards"),
deckName: z.string().min(1),
description: z.string().optional(),
cards: z
.array(
z.object({
front: z.string().min(1),
back: z.string().min(1),
})
)
.min(1),
});
deckName: nameSchema,
description: descriptionSchema.optional(),
cards: z.array(cardContentSchema).min(1).max(CONTENT_LIMITS.cards),
}).strict();
const importEnvelopeBase = {
classId: idSchema,
name: nameSchema.optional(),
groupId: idSchema.nullish(),
};
export const deckImportRequestSchema = z.object({
...importEnvelopeBase,
data: flashcardImportSchema,
}).strict();
export const quizImportRequestSchema = z.object({
...importEnvelopeBase,
data: quizImportSchema,
}).strict();
export type QuizImportData = z.infer<typeof quizImportSchema>;
export type FlashcardImportData = z.infer<typeof flashcardImportSchema>;

View file

@ -0,0 +1,19 @@
import { z } from "zod";
import { idSchema, nameSchema } from "@/lib/validation/contentSchemas";
export const materialGroupQuerySchema = z.object({
classId: idSchema,
type: z.enum(["DECK", "QUIZ"]).optional(),
});
export const materialGroupCreateSchema = z
.object({
classId: idSchema,
name: nameSchema,
type: z.enum(["DECK", "QUIZ"]),
})
.strict();
export const materialGroupUpdateSchema = z
.object({ name: nameSchema })
.strict();

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
progressDeleteSchema,
progressPatchSchema,
progressQuerySchema,
} from "./progressSchemas";
describe("progress schemas", () => {
const validDeckPatch = {
contentType: "DECK" as const,
contentId: "deck",
mode: "SEQUENTIAL" as const,
currentIndex: 0,
order: ["card"],
cardResults: {},
sessionId: "session",
revision: 1,
};
it("accepts a complete typed deck payload", () => {
expect(progressPatchSchema.safeParse(validDeckPatch).success).toBe(true);
});
it.each([
{ ...validDeckPatch, currentIndex: -1 },
{ ...validDeckPatch, revision: 0 },
{ ...validDeckPatch, order: ["card", "card"] },
{ ...validDeckPatch, cardResults: { card: "unknown" } },
{ ...validDeckPatch, orderJson: '["card"]' },
])("rejects malformed progress writes", (payload) => {
expect(progressPatchSchema.safeParse(payload).success).toBe(false);
});
it("validates query enums and conditional deletion identity", () => {
expect(
progressQuerySchema.safeParse({
contentType: "UNKNOWN",
contentId: "id",
mode: "SEQUENTIAL",
}).success
).toBe(false);
expect(
progressDeleteSchema.safeParse({
contentType: "DECK",
contentId: "id",
mode: "SEQUENTIAL",
}).success
).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import { z } from "zod";
const contentTypeSchema = z.enum(["DECK", "QUIZ"]);
const modeSchema = z.enum(["SEQUENTIAL", "SHUFFLED"]);
const idSchema = z.string().min(1).max(100);
const orderSchema = z
.array(idSchema)
.max(100_000)
.refine((ids) => new Set(ids).size === ids.length, {
message: "Progress order IDs must be unique",
});
const answersSchema = z.record(
z.string(),
z
.array(idSchema)
.max(100)
.refine((ids) => new Set(ids).size === ids.length, {
message: "Selected answer IDs must be unique",
})
);
export const progressQuerySchema = z.object({
contentType: contentTypeSchema,
contentId: idSchema,
mode: modeSchema,
});
const progressBase = {
contentId: idSchema,
mode: modeSchema,
currentIndex: z.number().int().nonnegative(),
order: orderSchema,
sessionId: idSchema,
revision: z.number().int().positive(),
};
export const progressPatchSchema = z.discriminatedUnion("contentType", [
z
.object({
...progressBase,
contentType: z.literal("DECK"),
cardResults: z.record(z.string(), z.enum(["correct", "missed"])),
})
.strict(),
z
.object({
...progressBase,
contentType: z.literal("QUIZ"),
answers: answersSchema,
})
.strict(),
]);
export const progressDeleteSchema = z
.object({
contentType: contentTypeSchema,
contentId: idSchema,
mode: modeSchema,
sessionId: idSchema,
})
.strict();
export type ProgressPatchInput = z.infer<typeof progressPatchSchema>;
export type ProgressDeleteInput = z.infer<typeof progressDeleteSchema>;

View file

@ -1,11 +1,22 @@
import { z } from "zod";
const reorderItemSchema = z.object({
id: z.string().min(1),
sortOrder: z.number().int(),
groupId: z.string().nullable(),
});
const reorderItemSchema = z
.object({
id: z.string().min(1).max(100),
groupId: z.string().min(1).max(100).nullable(),
})
.strict();
export const reorderRequestSchema = z.object({
items: z.array(reorderItemSchema),
});
export const reorderRequestSchema = z
.object({
items: z
.array(reorderItemSchema)
.min(1)
.max(100_000)
.refine((items) => new Set(items.map((item) => item.id)).size === items.length, {
message: "Reorder item IDs must be unique",
}),
})
.strict();
export type ReorderInput = z.infer<typeof reorderRequestSchema>;

View file

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { shareTargetSchema } from "./shareSchemas";
describe("shareTargetSchema", () => {
it.each(["DECK", "QUIZ", "GROUP"])("accepts target type %s", (targetType) => {
expect(shareTargetSchema.safeParse({ targetType, contentId: "id" }).success).toBe(true);
});
it.each(["", "CLASS", "deck", null])("rejects target type %s", (targetType) => {
expect(shareTargetSchema.safeParse({ targetType, contentId: "id" }).success).toBe(false);
});
});

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const shareTargetSchema = z
.object({
targetType: z.enum(["DECK", "QUIZ", "GROUP"]),
contentId: z.string().min(1).max(100),
})
.strict();