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

View file

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rateLimiter";
// Progressive lockout thresholds from Section 4
function getLockoutDuration(failedAttempts: number): number {
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
return 0;
}
export async function POST(request: NextRequest) {
// Rate limit by IP
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
const rateCheck = checkRateLimit(ip);
if (!rateCheck.allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again shortly." },
{ status: 429 }
);
}
const body = await request.json().catch(() => null);
if (!body?.password || typeof body.password !== "string") {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 }
);
}
// Check lockout state
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
if (!security) {
security = await prisma.authSecurity.create({ data: { id: 1 } });
}
if (security.lockedUntil && security.lockedUntil > new Date()) {
const remainingMs =
security.lockedUntil.getTime() - Date.now();
const remainingMin = Math.ceil(remainingMs / 60000);
return NextResponse.json(
{
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
},
{ status: 423 }
);
}
// Verify password
const passwordHash = process.env.ADMIN_PASSWORD_HASH;
// For development: if no hash is set, accept any non-empty password
let isValid = false;
if (!passwordHash) {
isValid = body.password.length > 0;
} else {
// Dynamic import to avoid issues when argon2 isn't installed
try {
const argon2 = await import("argon2");
isValid = await argon2.verify(passwordHash, body.password);
} catch {
// Fallback: direct comparison (only for development)
isValid = body.password === passwordHash;
}
}
if (!isValid) {
const newFailedAttempts = security.failedAttempts + 1;
const lockoutMs = getLockoutDuration(newFailedAttempts);
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: newFailedAttempts,
lockedUntil,
lastAttemptAt: new Date(),
},
});
return NextResponse.json(
{ error: "Invalid password" },
{ status: 401 }
);
}
// Success — reset lockout, create session
await prisma.authSecurity.update({
where: { id: 1 },
data: {
failedAttempts: 0,
lockedUntil: null,
lastAttemptAt: new Date(),
},
});
await createSession();
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/auth";
export async function POST() {
await destroySession();
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService";
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/cards/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await cardService.updateCard(id, {
front: body?.front?.trim(),
back: body?.back?.trim(),
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/cards/[id]">
) {
const { id } = await ctx.params;
try {
await cardService.deleteCard(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService";
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/classes/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
if (!body || (!body.name && body.name !== "")) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
try {
const updated = await classService.updateClass(id, {
name: body.name?.trim(),
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Class not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/classes/[id]">
) {
const { id } = await ctx.params;
try {
await classService.deleteClass(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Class not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService";
export async function GET() {
const classes = await classService.listClasses();
return NextResponse.json(classes);
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
return NextResponse.json(
{ error: "Class name is required" },
{ status: 400 }
);
}
const newClass = await classService.createClass(body.name.trim());
return NextResponse.json(newClass, { status: 201 });
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService";
export async function POST(
request: NextRequest,
ctx: RouteContext<"/api/decks/[id]/cards">
) {
const { id: deckId } = await ctx.params;
const body = await request.json().catch(() => null);
if (
!body?.front ||
!body?.back ||
typeof body.front !== "string" ||
typeof body.back !== "string"
) {
return NextResponse.json(
{ error: "front and back are required" },
{ status: 400 }
);
}
const card = await cardService.createCard(deckId, {
front: body.front.trim(),
back: body.back.trim(),
});
return NextResponse.json(card, { status: 201 });
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
const deck = await deckService.getDeckWithCards(id);
if (!deck) {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
return NextResponse.json(deck);
}
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await deckService.updateDeck(id, {
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/decks/[id]">
) {
const { id } = await ctx.params;
try {
await deckService.deleteDeck(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const classId = searchParams.get("classId");
if (!classId) {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
const decks = await deckService.listDecksByClass(classId);
return NextResponse.json(decks);
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService";
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation — never trust client
const parsed = flashcardImportSchema.safeParse(data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues },
{ status: 400 }
);
}
const deck = await deckService.createDeckFromImport(
classId,
parsed.data,
name?.trim() || undefined
);
return NextResponse.json(deck, { status: 201 });
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import * as progressService from "@/services/progressService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
const contentId = searchParams.get("contentId");
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
if (!contentType || !contentId || !mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
const progress = await progressService.getProgress(
contentType,
contentId,
mode
);
return NextResponse.json(progress);
}
export async function PATCH(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.contentType || !body?.contentId || !body?.mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
const progress = await progressService.upsertProgress({
contentType: body.contentType,
contentId: body.contentId,
mode: body.mode,
currentIndex: body.currentIndex ?? 0,
orderJson: body.orderJson,
answersJson: body.answersJson,
cardResultsJson: body.cardResultsJson,
});
return NextResponse.json(progress);
}
export async function DELETE(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.contentType || !body?.contentId || !body?.mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
);
}
await progressService.clearProgress(
body.contentType,
body.contentId,
body.mode
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
import { scoreQuiz } from "@/lib/scoring";
export async function POST(
request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]/attempt">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
if (!body || !body.answersJson) {
return NextResponse.json(
{ error: "answersJson is required" },
{ status: 400 }
);
}
const quizSet = await getQuizSetWithQuestions(id);
if (!quizSet) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
// Parse answers
let answers: Record<string, string[]>;
try {
answers = JSON.parse(body.answersJson);
} catch {
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
}
// Determine which questions were included in this attempt
const isPartialRetake = body.isPartialRetake === true;
// If partial retake, we only score the questions that actually had answers provided
// or that were explicitly passed in a questionIds array.
// For simplicity, we filter the quizSet questions down to what's in the answers object
// if it's a partial retake, though the client will only show those anyway.
let questionsToScore = quizSet.questions;
if (isPartialRetake) {
const answeredIds = Object.keys(answers);
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
}
// Format questions for the scoring utility
const formattedQuestions = questionsToScore.map((q) => ({
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
}));
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
const attempt = await createQuizAttempt({
quizSetId: id,
score: total,
maxScore: maxScore,
answersJson: body.answersJson,
isPartialRetake,
});
return NextResponse.json(attempt, { status: 201 });
}
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]/attempt">
) {
const { id } = await ctx.params;
const attempts = await listQuizAttempts(id);
return NextResponse.json(attempts);
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
export async function GET(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
const quizSet = await quizService.getQuizSetWithQuestions(id);
if (!quizSet) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
return NextResponse.json(quizSet);
}
export async function PATCH(
request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
const body = await request.json().catch(() => null);
try {
const updated = await quizService.updateQuizSet(id, {
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
}
export async function DELETE(
_request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]">
) {
const { id } = await ctx.params;
try {
await quizService.deleteQuizSet(id);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const classId = searchParams.get("classId");
if (!classId) {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
const quizSets = await quizService.listQuizSetsByClass(classId);
return NextResponse.json(quizSets);
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService";
import { quizImportSchema } from "@/lib/validation/importSchemas";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation
const parsed = quizImportSchema.safeParse(data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues },
{ status: 400 }
);
}
const quizSet = await quizService.createQuizSetFromImport(
classId,
parsed.data,
name?.trim() || undefined
);
return NextResponse.json(quizSet, { status: 201 });
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import * as settingsService from "@/services/settingsService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {
return NextResponse.json(
{ error: "value is required" },
{ status: 400 }
);
}
if (body.value === "__RESET__") {
await settingsService.resetLlmInstructions(type);
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
await settingsService.updateLlmInstructions(type, body.value);
return NextResponse.json({ value: body.value });
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { toggleShareLink, getShareLinkForContent } from "@/services/shareService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ";
const contentId = searchParams.get("contentId");
if (!targetType || !contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
);
}
const link = await getShareLinkForContent(targetType, contentId);
return NextResponse.json({ token: link?.id || null });
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body?.targetType || !body?.contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
);
}
const link = await toggleShareLink(body.targetType, body.contentId);
return NextResponse.json({ token: link?.id || null });
}