Refactor Study Desk application structure
This commit is contained in:
parent
faaccf8a7e
commit
089439ed90
145 changed files with 8087 additions and 3412 deletions
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/lib/limitedJson.test.ts
Normal file
20
src/lib/limitedJson.test.ts
Normal 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
24
src/lib/limitedJson.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/lib/progressNormalization.test.ts
Normal file
70
src/lib/progressNormalization.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
src/lib/progressNormalization.ts
Normal file
108
src/lib/progressNormalization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
src/lib/publicPaths.test.ts
Normal file
25
src/lib/publicPaths.test.ts
Normal 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
17
src/lib/publicPaths.ts
Normal 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
33
src/lib/quizSnapshots.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
src/lib/rateLimiter.test.ts
Normal file
30
src/lib/rateLimiter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
48
src/lib/scoring.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
43
src/lib/sessionConfig.test.ts
Normal file
43
src/lib/sessionConfig.test.ts
Normal 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
58
src/lib/sessionConfig.ts
Normal 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: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/lib/shareMetadata.test.ts
Normal file
34
src/lib/shareMetadata.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
35
src/lib/validation/attemptSchemas.test.ts
Normal file
35
src/lib/validation/attemptSchemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
src/lib/validation/attemptSchemas.ts
Normal file
23
src/lib/validation/attemptSchemas.ts
Normal 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>;
|
||||
43
src/lib/validation/contentSchemas.ts
Normal file
43
src/lib/validation/contentSchemas.ts
Normal 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;
|
||||
}
|
||||
84
src/lib/validation/importSchemas.test.ts
Normal file
84
src/lib/validation/importSchemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
19
src/lib/validation/materialGroupSchemas.ts
Normal file
19
src/lib/validation/materialGroupSchemas.ts
Normal 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();
|
||||
50
src/lib/validation/progressSchemas.test.ts
Normal file
50
src/lib/validation/progressSchemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
src/lib/validation/progressSchemas.ts
Normal file
64
src/lib/validation/progressSchemas.ts
Normal 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>;
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
12
src/lib/validation/shareSchemas.test.ts
Normal file
12
src/lib/validation/shareSchemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
8
src/lib/validation/shareSchemas.ts
Normal file
8
src/lib/validation/shareSchemas.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue