fixed 2 bugs that was left for the new team

This commit is contained in:
Erza Bytyci
2026-05-25 12:11:05 +00:00
parent 690e9df7b2
commit 4bd48253cf
16 changed files with 397 additions and 127 deletions
+18 -21
View File
@@ -3,10 +3,9 @@
import { Column, Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import { useRequest } from "@/shared/hooks/useRequest";
import { useState, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useIsSuperAdmin, useUser } from "@/context/user.context";
import { useRouter } from "next/navigation";
import { useUser } from "@/context/user.context";
/**
* UsersPage
@@ -19,30 +18,24 @@ import { useRouter } from "next/navigation";
* - only superadmins may access it
*/
export default function UsersPage() {
const router = useRouter();
const { auth } = useUser();
const isSuperAdmin = useIsSuperAdmin();
const canViewUsers =
auth.isAuthResolved && auth.isLoggedIn && auth.user?.is_superadmin;
const { data: users, error, isLoading, refetch } = useQuery("/admin/users");
const {
data: users,
error,
isLoading,
refetch,
} = useQuery("/admin/users", undefined, {
enabled: canViewUsers,
});
const makeRequest = useRequest();
const [updatingId, setUpdatingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [search, setSearch] = useState("");
/**
* Redirect unauthorized users away from this page.
*
* The `useIsSuperAdmin` hook already performs route protection logic,
* but this extra redirect keeps the page behavior explicit and prevents
* the user from remaining on `/admin/users` after losing access.
*/
useEffect(() => {
if (isSuperAdmin === false) {
router.replace("/");
}
}, [isSuperAdmin, router]);
/**
* Filter users by username or email.
*
@@ -62,11 +55,15 @@ export default function UsersPage() {
});
}, [users, search]);
// Wait until the superadmin check has resolved before rendering the page.
if (isSuperAdmin === undefined) {
// Wait until the auth check has resolved before rendering the page.
if (!auth.isAuthResolved) {
return <p>Wird geladen...</p>;
}
if (!auth.isLoggedIn || !auth.user?.is_superadmin) {
return <p className="error">Sie haben keinen Zugriff auf diese Seite.</p>;
}
if (isLoading) {
return <p>Wird geladen...</p>;
}
@@ -1,6 +1,7 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export async function GET(request, { params }) {
export const GET = withAuth(async (request, { params }) => {
const { dnd_id } = await params;
if (!dnd_id || isNaN(parseInt(dnd_id, 10))) {
@@ -64,4 +65,4 @@ export async function GET(request, { params }) {
{ status: 500 }
);
}
}
});
@@ -80,6 +80,7 @@ describe("freeform answers", () => {
expect(result.similarity).toBe(1);
expect(result.perfectAnswer).toBe(true);
expect(result.feedback).toBe("Great job!");
expect(result.answer).toBeUndefined();
});
it("should evaluate similar but not exact answers", async () => {
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "./route";
vi.mock("@/backend/auth/session", () => ({
checkSession: vi.fn(),
}));
vi.mock("@/backend/db", () => ({
DB: {
pool: vi.fn(),
},
}));
import { checkSession } from "@/backend/auth/session";
import { DB } from "@/backend/db";
function createRequest(body) {
return {
url: "http://localhost/api/exercises/freeform/answers",
headers: new Headers({ "content-type": "application/json" }),
method: "POST",
cache: "default",
credentials: "same-origin",
integrity: "",
mode: "cors",
redirect: "follow",
json: async () => body,
};
}
describe("freeform answer response shape", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not echo the canonical answer", async () => {
checkSession.mockResolvedValue({ id: 7, is_admin: false });
DB.pool
.mockResolvedValueOnce({
rows: [
{
id: 1,
answer: "This is the correct answer",
is_correct: true,
feedback: "Great job!",
},
],
})
.mockResolvedValueOnce({ rows: [] });
const response = await POST(
createRequest({
freeFormExerciseId: "12",
freeFormQuestionId: "34",
answer: "This is the correct answer",
})
);
expect(response.status).toBe(200);
const result = await response.json();
expect(result.answer).toBeUndefined();
expect(result.is_correct).toBe(true);
expect(result.perfectAnswer).toBe(true);
});
});
@@ -260,7 +260,6 @@ export const POST = withAuth(
return NextResponse.json({
is_correct: false,
answer: "",
feedback:
"Your answer doesn't match any of the expected answers. Please try again.",
similarity: bestMatch.similarity,
@@ -307,7 +306,6 @@ export const POST = withAuth(
return NextResponse.json({
is_correct: isAnswerCorrect,
answer: bestMatch.answer,
similarity: bestMatch.similarity,
perfectAnswer,
comparisonDetails,
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export async function GET(request, { params }) {
export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
try {
const exRes = await DB.pool(
@@ -29,65 +30,71 @@ export async function GET(request, { params }) {
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
});
export async function PUT(request, { params }) {
const { id } = await params;
const body = await request.json();
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors },
{ status: 400 }
);
}
const { title, sentences } = body;
try {
await DB.transaction(async (client) => {
await client.query(
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
export const PUT = withAuth(
async (request, { params }) => {
const { id } = await params;
const body = await request.json();
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors },
{ status: 400 }
);
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
for (const s of sentences) {
}
const { title, sentences } = body;
try {
await DB.transaction(async (client) => {
await client.query(
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
s.sentence,
s.alternates?.filter(Boolean) || [],
s.correctSentenceFeedback || null,
s.alternateFeedbacks || [],
s.incorrectFeedbacks || [],
s.incorrectAlternates || [],
]
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
);
}
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
for (const s of sentences) {
await client.query(
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
s.sentence,
s.alternates?.filter(Boolean) || [],
s.correctSentenceFeedback || null,
s.alternateFeedbacks || [],
s.incorrectFeedbacks || [],
s.incorrectAlternates || [],
]
);
}
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
export async function DELETE(request, { params }) {
const { id } = await params;
try {
await DB.transaction(async (client) => {
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
await client.query(
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export const DELETE = withAuth(
async (request, { params }) => {
const { id } = await params;
try {
await DB.transaction(async (client) => {
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
await client.query(
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export async function GET(request, { params }) {
export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
if (!id || Number.isNaN(Number(id))) {
@@ -48,4 +49,4 @@ export async function GET(request, { params }) {
{ status: 500 }
);
}
}
});
@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const POST = withAuth(async (request, { params }) => {
const { id } = await params;
const body = await request.json();
const answers = Array.isArray(body?.answers) ? body.answers : [];
if (!id || Number.isNaN(Number(id))) {
return NextResponse.json(
{ error: "Invalid exercise ID." },
{ status: 400 }
);
}
if (answers.length === 0) {
return NextResponse.json(
{ error: "Answers are required." },
{ status: 422 }
);
}
try {
const { rows: contentRows } = await DB.pool(
`SELECT id, content_type, correct_answer
FROM multichoice_content
WHERE multichoice_exercise_id = $1
ORDER BY content_order`,
[id]
);
if (contentRows.length === 0) {
return NextResponse.json(
{ error: "Exercise not found." },
{ status: 404 }
);
}
const answerMap = new Map(
answers
.filter((entry) => entry && entry.contentId != null)
.map((entry) => [String(entry.contentId), String(entry.answer || "")])
);
const results = contentRows
.filter(
(item) =>
item.content_type === "multichoice" || item.content_type === "gap"
)
.map((item) => {
const submitted = (answerMap.get(String(item.id)) || "").trim();
const expected = String(item.correct_answer || "").trim();
const isCorrect =
submitted.length > 0 &&
submitted.toLowerCase() === expected.toLowerCase();
return {
contentId: item.id,
isCorrect,
answered: submitted.length > 0,
};
});
const hasMissing = results.some((entry) => !entry.answered);
const allCorrect =
results.length > 0 && results.every((entry) => entry.isCorrect);
return NextResponse.json({
results,
hasMissing,
allCorrect,
});
} catch (error) {
console.error("Error validating multichoice answers:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
});
@@ -1,7 +1,7 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export async function GET(request, { params }) {
// --- FIX IS HERE ---
export const GET = withAuth(async (request, { params }) => {
// In Next.js 15, params is a Promise, so you must await it.
const { id } = await params;
@@ -23,7 +23,15 @@ export async function GET(request, { params }) {
// Fetch exercise content
const contentResult = await DB.pool(
"SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1 ORDER BY content_order",
`SELECT
id,
multichoice_exercise_id,
content_type,
content_value,
content_order
FROM multichoice_content
WHERE multichoice_exercise_id = $1
ORDER BY content_order`,
[id]
);
const content = contentResult.rows;
@@ -61,4 +69,4 @@ export async function GET(request, { params }) {
{ status: 500 }
);
}
}
});
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export async function GET() {
export const GET = withAuth(async () => {
try {
// Fetch multichoice exercises
const { rows: exercises } = await DB.pool(`
@@ -30,8 +31,7 @@ export async function GET() {
mc.multichoice_exercise_id,
mc.content_type,
mc.content_value,
mc.content_order,
mc.correct_answer
mc.content_order
FROM
multichoice_content mc
WHERE
@@ -83,4 +83,4 @@ export async function GET() {
{ status: 500 }
);
}
}
});
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { checkSession } from "@/backend/auth/session";
import { GET as getClick } from "./click/route";
import { GET as getDragdrop } from "./dragdrop/[dnd_id]/route";
import { GET as getMultichoiceList } from "./multichoice/route";
import { GET as getMultichoiceDetail } from "./multichoice/[id]/route";
import { GET as getMemoryGame } from "./memory-game/[id]/route";
import {
GET as getJumbledSentence,
PUT as putJumbledSentence,
DELETE as deleteJumbledSentence,
} from "./jumbled-sentence/[id]/route";
vi.mock("@/backend/auth/session", () => ({
checkSession: vi.fn(),
}));
describe("exercise API auth guards", () => {
it("rejects unauthenticated access to answer-bearing routes", async () => {
checkSession.mockResolvedValue(null);
const cases = [
[getClick, {}],
[getDragdrop, { params: Promise.resolve({ dnd_id: "1" }) }],
[getMultichoiceList, {}],
[getMultichoiceDetail, { params: Promise.resolve({ id: "1" }) }],
[getMemoryGame, { params: Promise.resolve({ id: "1" }) }],
[getJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
[putJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
[deleteJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
];
for (const [handler, context] of cases) {
const response = await handler({}, context);
expect(response.status).toBe(401);
}
});
});
@@ -6,6 +6,7 @@ import { Row } from "../layout/container";
import "./multichoice.css";
import { Button } from "@/components/ui/button";
import useQuery from "@/shared/hooks/useQuery";
import { useRequest } from "@/shared/hooks/useRequest";
function shuffleOptions(array) {
return array
@@ -15,6 +16,7 @@ function shuffleOptions(array) {
}
export default function MultichoicePage({ exerciseId }) {
const makeRequest = useRequest();
// Fetch exercise data using the useQuery hook
const {
data: exerciseData,
@@ -75,37 +77,65 @@ export default function MultichoicePage({ exerciseId }) {
};
const handleSubmit = () => {
const hasMissingAnswers = userAnswers.some((answer) => answer === "");
const newCheckedAnswers = shuffledExerciseData.content.map(
(item, index) => {
if (
item.content_type === "multichoice" ||
item.content_type === "gap"
) {
return (
userAnswers[index]?.trim().toLowerCase() ===
item.correct_answer.toLowerCase()
);
}
return true;
(async () => {
try {
const answerEntries = shuffledExerciseData.content
.map((item, index) => ({ item, index }))
.filter(
({ item }) =>
item.content_type === "multichoice" || item.content_type === "gap"
)
.map(({ item, index }) => ({
contentId: item.id,
answer: userAnswers[index] || "",
}));
const response = await makeRequest(
`/exercises/multichoice/${exerciseId}/answers`,
{
answers: answerEntries,
}
);
const resultByContentId = new Map(
(response.data?.results || []).map((entry) => [
String(entry.contentId),
entry,
])
);
const newCheckedAnswers = shuffledExerciseData.content.map((item) => {
if (
item.content_type === "multichoice" ||
item.content_type === "gap"
) {
return !!resultByContentId.get(String(item.id))?.isCorrect;
}
return true;
});
const hasMissingAnswers = !!response.data?.hasMissing;
const hasIncorrectAnswers = newCheckedAnswers.some(
(isCorrect, index) => {
const contentType =
shuffledExerciseData.content[index].content_type;
const isAnswerField =
contentType === "multichoice" || contentType === "gap";
return isAnswerField && !isCorrect;
}
);
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);
setCheckedAnswers(newCheckedAnswers);
setIsSubmitted(true);
setDropdownSubmittedStates(newCheckedAnswers.map(() => true));
} catch (submitError) {
console.error("Error validating multichoice answers:", submitError);
setHasErrors(true);
setIsSubmitted(true);
}
);
const hasIncorrectAnswers = newCheckedAnswers.some((isCorrect, index) => {
const contentType = shuffledExerciseData.content[index].content_type;
const isAnswerField =
contentType === "multichoice" || contentType === "gap";
return isAnswerField && !isCorrect;
});
// Set error state if there are missing or incorrect answers
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);
setCheckedAnswers(newCheckedAnswers);
setIsSubmitted(true);
// Mark all dropdowns as submitted
setDropdownSubmittedStates(newCheckedAnswers.map(() => true));
})();
};
const handleReset = () => {
+5 -1
View File
@@ -183,6 +183,8 @@ export function useIsAdmin() {
const { auth, actAs } = useUser();
useEffect(() => {
if (!auth.isAuthResolved) return;
if (!auth.user?.id) return;
const isAllowedAdminView =
@@ -194,7 +196,9 @@ export function useIsAdmin() {
}
}, [auth, router, pathname]);
return auth?.user?.id ? auth.user.is_admin : undefined;
if (!auth.isAuthResolved) return undefined;
return auth.isLoggedIn && !!auth.user?.is_admin;
}
/**
+1 -1
View File
@@ -11,7 +11,7 @@ import { AUTH_COOKIE_NAME } from "@/shared/const";
const authRequired = ["/grammar/exercises", "/edit_info", "/contact"];
// Protected paths that should send logged-out users to registration first.
const registerFirstPaths = ["/contact"];
const registerFirstPaths = ["/contact", "/grammar/exercises"];
// Restricted paths logged-in users cannot access
/**
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { middleware } from "./middleware";
function createRequest(pathname, hasCookie = false) {
return {
url: `http://localhost/gradesa${pathname}`,
nextUrl: {
pathname: `/gradesa${pathname}`,
},
cookies: {
get: () => (hasCookie ? { value: "session-cookie" } : undefined),
},
};
}
describe("middleware", () => {
it("redirects logged-out users from exercises to registration", async () => {
const response = await middleware(createRequest("/grammar/exercises"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/auth/register");
expect(response.headers.get("location")).toContain(
"redirect=%2Fgrammar%2Fexercises"
);
});
it("redirects logged-out users from contact to registration", async () => {
const response = await middleware(createRequest("/contact"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/auth/register");
});
it("redirects other protected paths to login", async () => {
const response = await middleware(createRequest("/edit_info"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/auth/login");
});
});
-2
View File
@@ -18,8 +18,6 @@ For the future:
- The following things could be improved in the GitHub Actions pipeline:
- Make the tests faster by for example making e2e-tests run at the same time as other tests.
- Modify the tests so that the e2e-tests test the built container, not just the code. This way the tests catch if there are problems with for example dependencies marked only as dev-dependencies (since they are removed when the image is built).
- Fix this bug: If you go to the front page and select 'Übungen' from the 'Grammatik' box while you are not logged in you are brought to the login page. If you input valid credentials there and try to log in, you will see that nothing happens.
- Fix this: If you want to see the correct answers to some exercises, you can get the correct answers straight from the API by for example opening https://gradesa-ohtuprojekti-staging.ext.ocp-prod-0.k8s.it.helsinki.fi/api/exercises/dragdrop/1 in your browser. You do not even have to be logged in.
!!! ADHERE TO THE DEFINITION OF DONE !!!