Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
This commit is contained in:
parent
666ceb7325
commit
b7ce314f01
105 changed files with 35510 additions and 11 deletions
37
src/lib/auth.ts
Normal file
37
src/lib/auth.ts
Normal 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
19
src/lib/db.ts
Normal 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
51
src/lib/jsonRepair.ts
Normal 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
31
src/lib/rateLimiter.ts
Normal 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
41
src/lib/scoring.ts
Normal 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
35
src/lib/shuffle.ts
Normal 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 };
|
||||
}
|
||||
48
src/lib/validation/importSchemas.ts
Normal file
48
src/lib/validation/importSchemas.ts
Normal 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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue