Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s

This commit is contained in:
Elijah 2026-06-27 19:19:38 -07:00
parent 666ceb7325
commit b7ce314f01
105 changed files with 35510 additions and 11 deletions

37
src/lib/auth.ts Normal file
View file

@ -0,0 +1,37 @@
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
isAuthenticated: boolean;
}
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",
httpOnly: true,
sameSite: "lax" as const,
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function createSession() {
const session = await getSession();
session.isAuthenticated = true;
await session.save();
}
export async function destroySession() {
const session = await getSession();
session.destroy();
}
export async function isAuthenticated() {
const session = await getSession();
return session.isAuthenticated === true;
}

19
src/lib/db.ts Normal file
View file

@ -0,0 +1,19 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL ?? "file:./dev.db",
});
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

51
src/lib/jsonRepair.ts Normal file
View file

@ -0,0 +1,51 @@
import { jsonrepair } from "jsonrepair";
export interface RepairResult {
success: boolean;
data: unknown;
wasRepaired: boolean;
repairedJson?: string;
error?: string;
}
/**
* Parse strip fences repair return.
* Syntax is mechanical and safe to fix automatically.
* Content validation (Zod) runs separately.
*/
export function parseAndRepairJson(input: string): RepairResult {
// Step 1: Strip markdown code fences if present
let text = input.trim();
const fenceMatch = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/);
if (fenceMatch) {
text = fenceMatch[1].trim();
}
// Step 2: Try direct parse
try {
const data = JSON.parse(text);
return { success: true, data, wasRepaired: false };
} catch {
// Step 3: Try jsonrepair
try {
const repaired = jsonrepair(text);
const data = JSON.parse(repaired);
return {
success: true,
data,
wasRepaired: true,
repairedJson: repaired,
};
} catch (repairError) {
return {
success: false,
data: null,
wasRepaired: false,
error:
repairError instanceof Error
? repairError.message
: "Failed to parse or repair JSON",
};
}
}
}

31
src/lib/rateLimiter.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* In-memory sliding window rate limiter.
* Single-instance, single-user container no need for Redis.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10;
export function checkRateLimit(ip: string): { allowed: boolean; retryAfterMs: number } {
const now = Date.now();
const entry = store.get(ip) ?? { timestamps: [] };
// Remove timestamps outside the window
entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS);
if (entry.timestamps.length >= MAX_REQUESTS) {
const oldestInWindow = entry.timestamps[0];
const retryAfterMs = WINDOW_MS - (now - oldestInWindow);
return { allowed: false, retryAfterMs };
}
entry.timestamps.push(now);
store.set(ip, entry);
return { allowed: true, retryAfterMs: 0 };
}

41
src/lib/scoring.ts Normal file
View file

@ -0,0 +1,41 @@
type OptionLite = { id: string; isCorrect: boolean };
type QuestionLite = {
id: string;
type: "MULTIPLE_CHOICE" | "SATA";
options: OptionLite[];
};
export function scoreQuestion(
question: QuestionLite,
selectedIds: string[]
): number {
const correctIds = question.options
.filter((o) => o.isCorrect)
.map((o) => o.id);
if (question.type === "MULTIPLE_CHOICE") {
return correctIds.includes(selectedIds[0]) ? 1 : 0;
}
// SATA: partial credit
const correctSelected = selectedIds.filter((id) =>
correctIds.includes(id)
).length;
const incorrectSelected = selectedIds.filter(
(id) => !correctIds.includes(id)
).length;
return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;
}
export function scoreQuiz(
questions: QuestionLite[],
answers: Record<string, string[]>
) {
let total = 0;
const perQuestion = questions.map((q) => {
const points = scoreQuestion(q, answers[q.id] ?? []);
total += points;
return { questionId: q.id, points };
});
return { total, maxScore: questions.length, perQuestion };
}

35
src/lib/shuffle.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Fisher-Yates shuffle produces a random permutation.
* Returns a new array, does not mutate the input.
*/
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/**
* Generate a sequential order array [0, 1, 2, ..., n-1]
* mapped to the provided ids.
*/
export function generateOrder(ids: string[], mode: "SEQUENTIAL" | "SHUFFLED"): string[] {
return mode === "SHUFFLED" ? shuffle(ids) : [...ids];
}
/**
* Filter an order array to only include ids that still exist,
* and clamp the current index if it overflows.
* Handles the case where cards/questions are deleted mid-session.
*/
export function filterAndClampOrder(
orderJson: string[],
existingIds: Set<string>,
currentIndex: number
): { order: string[]; index: number } {
const filtered = orderJson.filter((id) => existingIds.has(id));
const clampedIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1));
return { order: filtered, index: clampedIndex };
}

View file

@ -0,0 +1,48 @@
import { z } from "zod";
const optionSchema = z.object({
text: z.string().min(1),
correct: z.boolean(),
});
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, {
message: "Each question needs at least one correct option",
})
.refine(
(q) =>
q.type !== "multiple_choice" ||
q.options.filter((o) => o.correct).length === 1,
{ message: "multiple_choice questions must have exactly one correct option" }
);
export const quizImportSchema = z.object({
type: z.literal("quiz"),
quizName: z.string().min(1),
description: z.string().optional(),
questions: z.array(questionSchema).min(1),
});
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),
});
export type QuizImportData = z.infer<typeof quizImportSchema>;
export type FlashcardImportData = z.infer<typeof flashcardImportSchema>;