mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Merge pull request #541 from erzabytyci/user-tiers
Add configurable instruction field across key exercise types (Free, Click, Multichoice, Lückentext, Satzmix)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.free_form_exercises
|
||||
ADD COLUMN IF NOT EXISTS instruction_text text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.multichoice_exercises
|
||||
ADD COLUMN IF NOT EXISTS instruction_text text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.fill_gap_exercises
|
||||
ADD COLUMN IF NOT EXISTS instruction_text text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.jumbled_sentence_exercises
|
||||
ADD COLUMN IF NOT EXISTS instruction_text text;
|
||||
@@ -421,7 +421,8 @@ CREATE TABLE public.fill_gap_exercises (
|
||||
exercise_id bigint UNIQUE NOT NULL,
|
||||
title text NOT NULL,
|
||||
source_text text NOT NULL,
|
||||
source_html text NOT NULL DEFAULT ''::text
|
||||
source_html text NOT NULL DEFAULT ''::text,
|
||||
instruction_text text
|
||||
);
|
||||
|
||||
CREATE INDEX fill_gap_exercises_search_fts_idx ON public.fill_gap_exercises USING gin (to_tsvector('simple'::regconfig, ((((COALESCE(title, ''::text) || ' '::text) || COALESCE(source_text, ''::text)) || ' '::text) || COALESCE(source_html, ''::text))));
|
||||
@@ -476,7 +477,8 @@ CREATE TABLE public.jumbled_sentence_exercises (
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
exercise_id bigint UNIQUE NOT NULL,
|
||||
created_by bigint NOT NULL,
|
||||
title text NOT NULL
|
||||
title text NOT NULL,
|
||||
instruction_text text
|
||||
);
|
||||
|
||||
CREATE INDEX jumbled_sentence_exercises_search_fts_idx ON public.jumbled_sentence_exercises USING gin (to_tsvector('simple'::regconfig, COALESCE(title, ''::text)));
|
||||
@@ -540,7 +542,8 @@ CREATE TABLE public.free_form_exercises (
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
exercise_id bigint NOT NULL,
|
||||
question text,
|
||||
title text NOT NULL
|
||||
title text NOT NULL,
|
||||
instruction_text text
|
||||
);
|
||||
|
||||
CREATE INDEX free_form_exercises_search_fts_idx ON public.free_form_exercises USING gin (to_tsvector('simple'::regconfig, COALESCE(title, ''::text)));
|
||||
@@ -752,7 +755,8 @@ CREATE TABLE public.multichoice_exercises (
|
||||
title text NOT NULL,
|
||||
exercise_description text,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
instruction_text text
|
||||
);
|
||||
|
||||
CREATE INDEX multichoice_exercises_search_fts_idx ON public.multichoice_exercises USING gin (to_tsvector('simple'::regconfig, ((COALESCE(title, ''::text) || ' '::text) || COALESCE(exercise_description, ''::text))));
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function CreateExercise() {
|
||||
const [error, setError] = useState(null);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [targetCategory, setTargetCategory] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const [allWordsHtml, setAllWordsHtml] = useState("");
|
||||
const [allWordsText, setAllWordsText] = useState("");
|
||||
const [selectedWords, setSelectedWords] = useState([]);
|
||||
@@ -159,7 +159,7 @@ export default function CreateExercise() {
|
||||
: "";
|
||||
|
||||
setTitle(exerciseData.title || "");
|
||||
setTargetCategory(exerciseData.category || "");
|
||||
setInstructionText(exerciseData.category || "");
|
||||
setAllWordsHtml(sourceHtml);
|
||||
setAllWordsText(text);
|
||||
setSelectedWords(
|
||||
@@ -369,7 +369,7 @@ export default function CreateExercise() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
targetCategory,
|
||||
instructionText,
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
@@ -393,7 +393,7 @@ export default function CreateExercise() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
targetCategory,
|
||||
instructionText,
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
@@ -498,13 +498,13 @@ export default function CreateExercise() {
|
||||
</Container>
|
||||
|
||||
<Container className="exercise-click click-block">
|
||||
<label>Zielkategorie</label>
|
||||
<label>Anweisung</label>
|
||||
<input
|
||||
className="click-input"
|
||||
type="text"
|
||||
value={targetCategory}
|
||||
onChange={(e) => setTargetCategory(e.target.value)}
|
||||
placeholder="Z. B. Verben, Substantive, Adjektive, etc."
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Z. B. Markiere nur attributive Adjektive."
|
||||
required
|
||||
/>
|
||||
</Container>
|
||||
@@ -526,10 +526,6 @@ export default function CreateExercise() {
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<p>
|
||||
Klicken Sie auf die Wörter, um die richtigen auszuwählen (
|
||||
{targetCategory}).
|
||||
</p>
|
||||
<p>
|
||||
Wenn mehrere benachbarte ausgewählte Wörter als ein Element
|
||||
behandelt werden sollen, klicken Sie auf "Als Element übernehmen".
|
||||
@@ -540,7 +536,8 @@ export default function CreateExercise() {
|
||||
<Container>
|
||||
<WordSelectionExercise
|
||||
title={title}
|
||||
targetCategory={targetCategory}
|
||||
targetCategory={instructionText}
|
||||
instructionText={instructionText}
|
||||
targetWords={selectedWords}
|
||||
allWords={allWords}
|
||||
sourceHtml={allWordsHtml}
|
||||
@@ -576,7 +573,7 @@ export default function CreateExercise() {
|
||||
<Container className="click-block">
|
||||
<WordSelectionExercise
|
||||
title={title}
|
||||
targetCategory={targetCategory}
|
||||
targetCategory={instructionText}
|
||||
targetWords={[]}
|
||||
allWords={allWords}
|
||||
sourceHtml={allWordsHtml}
|
||||
|
||||
@@ -121,6 +121,7 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
const isEditMode = Boolean(exercise_id);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const [textHtml, setTextHtml] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [selectedIndices, setSelectedIndices] = useState([]);
|
||||
@@ -174,6 +175,7 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
}
|
||||
|
||||
setTitle(exerciseData.title || "");
|
||||
setInstructionText(exerciseData.instruction_text || "");
|
||||
const sourceText = exerciseData.source_text || "";
|
||||
setText(sourceText);
|
||||
setTextHtml(
|
||||
@@ -333,6 +335,7 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
instructionText,
|
||||
text,
|
||||
textHtml,
|
||||
gaps,
|
||||
@@ -400,6 +403,17 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="fitg-block">
|
||||
<label htmlFor="fitg-instruction">Anweisung</label>
|
||||
<input
|
||||
id="fitg-instruction"
|
||||
className="fitg-input"
|
||||
value={instructionText}
|
||||
onChange={(event) => setInstructionText(event.target.value)}
|
||||
placeholder="Optional: Hinweise für die Lernenden"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="fitg-block">
|
||||
<label>Übungstext</label>
|
||||
<Editor
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function EditFreeFormExercisePage() {
|
||||
const { exercise_id } = useParams();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [generalError, setGeneralError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -27,6 +28,7 @@ export default function EditFreeFormExercisePage() {
|
||||
if (data.title) {
|
||||
setTitle(data.title);
|
||||
}
|
||||
setInstructionText(data.instruction_text || "");
|
||||
if (data.questions) {
|
||||
console.log("Fetched questions:", data.questions);
|
||||
console.log("Loaded full data for edit:", data);
|
||||
@@ -138,7 +140,7 @@ export default function EditFreeFormExercisePage() {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title, questions }),
|
||||
body: JSON.stringify({ title, instructionText, questions }),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -194,6 +196,16 @@ export default function EditFreeFormExercisePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Anweisung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Optional: Hinweise für die Lernenden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{questions.map((q, questionIndex) => (
|
||||
<Column
|
||||
key={questionIndex}
|
||||
|
||||
@@ -9,11 +9,13 @@ import "../multichoice/multichoice.css";
|
||||
|
||||
const defaultFormErrors = {
|
||||
title: "",
|
||||
instructionText: "",
|
||||
questions: [],
|
||||
};
|
||||
|
||||
export default function CreateFreeFormExercise() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const [questions, setQuestions] = useState([
|
||||
{
|
||||
question: "",
|
||||
@@ -118,7 +120,7 @@ export default function CreateFreeFormExercise() {
|
||||
|
||||
const res = await makeRequest("/admin/exercises/free-form", {
|
||||
method: "POST",
|
||||
body: { title, questions },
|
||||
body: { title, instructionText, questions },
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
@@ -155,6 +157,7 @@ export default function CreateFreeFormExercise() {
|
||||
const handleSuccessOk = () => {
|
||||
setShowSuccessDialog(false);
|
||||
setTitle("");
|
||||
setInstructionText("");
|
||||
setQuestions([
|
||||
{
|
||||
question: "",
|
||||
@@ -205,6 +208,18 @@ export default function CreateFreeFormExercise() {
|
||||
<span className="field-error-hint">{formErrors.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Anweisung</label>
|
||||
<input
|
||||
className={`form-input${formErrors.instructionText ? " input-error" : ""}`}
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Optional: Hinweise für die Lernenden"
|
||||
/>
|
||||
{formErrors.instructionText && (
|
||||
<span className="field-error-hint">{formErrors.instructionText}</span>
|
||||
)}
|
||||
</div>
|
||||
{questions.map((q, questionIndex) => (
|
||||
<Column
|
||||
key={questionIndex}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
const { exercise_id } = useParams();
|
||||
const isEditMode = Boolean(exercise_id);
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const [sentences, setSentences] = useState([
|
||||
{
|
||||
sentence: "",
|
||||
@@ -60,6 +61,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
|
||||
const loadedExercise = data.exercise;
|
||||
setTitle(loadedExercise.title || "");
|
||||
setInstructionText(loadedExercise.instruction_text || "");
|
||||
setSentences(
|
||||
(loadedExercise.sentences || []).length
|
||||
? loadedExercise.sentences.map((item) => ({
|
||||
@@ -271,6 +273,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
|
||||
const parsed = jumbledSentenceExerciseSchema.safeParse({
|
||||
title,
|
||||
instructionText,
|
||||
sentences: normalizedSentences,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
@@ -289,7 +292,11 @@ export default function CreateJumbledSentenceExercise() {
|
||||
const res = await fetch(endpoint, {
|
||||
method: isEditMode ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, sentences: normalizedSentences }),
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
instructionText,
|
||||
sentences: normalizedSentences,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
|
||||
@@ -334,6 +341,15 @@ export default function CreateJumbledSentenceExercise() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.fieldLabel}>
|
||||
Anweisung:
|
||||
<input
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
className={styles.fieldInput}
|
||||
placeholder="Optional: Hinweise für die Lernenden"
|
||||
/>
|
||||
</label>
|
||||
<p className={styles.exerciseDescription}>
|
||||
Geben Sie den Satz in der richtigen Reihenfolge und optional in
|
||||
alternativen Reihenfolgen <br></br> mit denselben Elementen ein. Die
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function CreateMultichoicePage() {
|
||||
const isEditMode = Boolean(exercise_id);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
|
||||
const [sections, setSections] = useState([
|
||||
{ id: "1", type: "text", value: "Ich" },
|
||||
@@ -48,6 +49,9 @@ export default function CreateMultichoicePage() {
|
||||
}
|
||||
|
||||
setTitle(exerciseData.title || "");
|
||||
setInstructionText(
|
||||
exerciseData.instruction_text || exerciseData.exercise_description || ""
|
||||
);
|
||||
|
||||
const mappedSections = (exerciseData.content || []).map((item) => {
|
||||
if (item.content_type === "multichoice") {
|
||||
@@ -183,6 +187,7 @@ export default function CreateMultichoicePage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
instructionText,
|
||||
// --- FIX 2: REMOVED description from here ---
|
||||
content: contentToSave,
|
||||
}),
|
||||
@@ -248,6 +253,15 @@ export default function CreateMultichoicePage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Anweisung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Optional: Hinweise für die Lernenden"
|
||||
/>
|
||||
</div>
|
||||
<hr className="divider" />
|
||||
|
||||
{/* --- The Sentence Builder (Inline Flow) --- */}
|
||||
|
||||
@@ -11,8 +11,8 @@ function sanitizeHtml(html) {
|
||||
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
|
||||
}
|
||||
|
||||
function validatePayload({ title, targetCategory, targetWords, allWords }) {
|
||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||
function validatePayload({ title, instructionText, targetWords, allWords }) {
|
||||
if (!title || !instructionText || !targetWords || !allWords) {
|
||||
return { error: "Alle Felder sind erforderlich.", status: 400 };
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ function validatePayload({ title, targetCategory, targetWords, allWords }) {
|
||||
};
|
||||
}
|
||||
|
||||
if (targetCategory.length < 3 || targetCategory.length > 30) {
|
||||
if (instructionText.length < 5 || instructionText.length > 200) {
|
||||
return {
|
||||
error: "Die Kategorie muss zwischen 3 und 30 Zeichen lang sein.",
|
||||
error: "Die Anweisung muss zwischen 5 und 200 Zeichen lang sein.",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
@@ -110,7 +110,17 @@ export const PUT = withAuth(
|
||||
try {
|
||||
const { click_id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
|
||||
const {
|
||||
title,
|
||||
instructionText,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sourceHtml,
|
||||
} = body;
|
||||
const exerciseInstruction = String(
|
||||
instructionText ?? targetCategory ?? ""
|
||||
).trim();
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
const falseWordFeedbacks = Array.isArray(body.falseWordFeedbacks)
|
||||
? body.falseWordFeedbacks
|
||||
@@ -124,7 +134,7 @@ export const PUT = withAuth(
|
||||
|
||||
const validationError = validatePayload({
|
||||
title,
|
||||
targetCategory,
|
||||
instructionText: exerciseInstruction,
|
||||
targetWords,
|
||||
allWords,
|
||||
});
|
||||
@@ -176,7 +186,7 @@ export const PUT = withAuth(
|
||||
WHERE id = $6`,
|
||||
[
|
||||
title,
|
||||
targetCategory,
|
||||
exerciseInstruction,
|
||||
targetWords,
|
||||
allWords,
|
||||
sanitizedSourceHtml,
|
||||
|
||||
@@ -53,17 +53,21 @@ export const POST = withAuth(
|
||||
const json = await request.json();
|
||||
const {
|
||||
title,
|
||||
instructionText,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sourceHtml,
|
||||
falseWordFeedbacks,
|
||||
} = json;
|
||||
const exerciseInstruction = String(
|
||||
instructionText ?? targetCategory ?? ""
|
||||
).trim();
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
const normalizedFalseWordFeedbacks =
|
||||
normalizeFalseWordFeedbacks(falseWordFeedbacks);
|
||||
|
||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||
if (!title || !exerciseInstruction || !targetWords || !allWords) {
|
||||
return Response.json(
|
||||
{ error: "Alle Felder sind erforderlich." },
|
||||
{ status: 400 }
|
||||
@@ -75,9 +79,11 @@ export const POST = withAuth(
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (targetCategory.length < 3 || targetCategory.length > 30) {
|
||||
if (exerciseInstruction.length < 5 || exerciseInstruction.length > 200) {
|
||||
return Response.json(
|
||||
{ error: "Die Kategorie muss zwischen 3 und 30 Zeichen lang sein." },
|
||||
{
|
||||
error: "Die Anweisung muss zwischen 5 und 200 Zeichen lang sein.",
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
@@ -122,7 +128,7 @@ export const POST = withAuth(
|
||||
const insertResult = await tx.query(
|
||||
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
||||
VALUES ($1, $2, $3, $4, $5) returning id`,
|
||||
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
||||
[title, exerciseInstruction, targetWords, allWords, sanitizedSourceHtml]
|
||||
);
|
||||
|
||||
const clickExerciseId = insertResult.rows[0].id;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("create click_exercises API", () => {
|
||||
"Der Titel muss zwischen 3 und 50 Zeichen lang sein."
|
||||
);
|
||||
});
|
||||
it("should return 422 if targetCategory is too short", async () => {
|
||||
it("should return 422 if instruction text is too short", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
@@ -128,17 +128,17 @@ describe("create click_exercises API", () => {
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe(
|
||||
"Die Kategorie muss zwischen 3 und 30 Zeichen lang sein."
|
||||
"Die Anweisung muss zwischen 5 und 200 Zeichen lang sein."
|
||||
);
|
||||
});
|
||||
it("should return 422 if targetCategory is too long", async () => {
|
||||
it("should return 422 if instruction text is too long", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
const response = await POST(
|
||||
mockPost("api/exercises/create/click", {
|
||||
title: "Verben identifizieren",
|
||||
targetCategory: "a".repeat(31),
|
||||
targetCategory: "a".repeat(201),
|
||||
targetWords: ["laufen", "springen", "schwimmen"],
|
||||
allWords: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
|
||||
})
|
||||
@@ -148,7 +148,7 @@ describe("create click_exercises API", () => {
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe(
|
||||
"Die Kategorie muss zwischen 3 und 30 Zeichen lang sein."
|
||||
"Die Anweisung muss zwischen 5 und 200 Zeichen lang sein."
|
||||
);
|
||||
});
|
||||
it("should return 422 if targetWords is empty", async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const GET = withAuth(
|
||||
fge.id,
|
||||
fge.exercise_id,
|
||||
fge.title,
|
||||
fge.instruction_text,
|
||||
fge.source_text,
|
||||
fge.source_html,
|
||||
e.updated_at AS last_modified_at,
|
||||
@@ -110,7 +111,8 @@ export const PUT = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
const { title, text, textHtml, gaps } = parsed.data;
|
||||
const { title, instructionText, text, textHtml, gaps } = parsed.data;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
const normalizedText = normalizePlainText(
|
||||
text || htmlToPlainText(textHtml || "")
|
||||
);
|
||||
@@ -130,9 +132,19 @@ export const PUT = withAuth(
|
||||
|
||||
await tx.query(
|
||||
`UPDATE fill_gap_exercises
|
||||
SET title = $1, source_text = $2, source_html = $3, updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[title, normalizedText, sourceHtml, exercise_id]
|
||||
SET title = $1,
|
||||
instruction_text = $2,
|
||||
source_text = $3,
|
||||
source_html = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5`,
|
||||
[
|
||||
title,
|
||||
normalizedInstructionText || null,
|
||||
normalizedText,
|
||||
sourceHtml,
|
||||
exercise_id,
|
||||
]
|
||||
);
|
||||
|
||||
await tx.query(
|
||||
|
||||
@@ -19,7 +19,8 @@ function sanitizeHtml(html) {
|
||||
export const POST = withAuth(
|
||||
withInputValidation(fillGapCreateSchema, async (request) => {
|
||||
const body = await request.json();
|
||||
const { title, text, textHtml, gaps } = body;
|
||||
const { title, instructionText, text, textHtml, gaps } = body;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
const normalizedText = normalizePlainText(
|
||||
text || htmlToPlainText(textHtml || "")
|
||||
);
|
||||
@@ -41,10 +42,16 @@ export const POST = withAuth(
|
||||
const exerciseId = exerciseResult.rows[0].id;
|
||||
|
||||
const fillGapExerciseResult = await tx.query(
|
||||
`INSERT INTO fill_gap_exercises (exercise_id, title, source_text, source_html)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`INSERT INTO fill_gap_exercises (exercise_id, title, instruction_text, source_text, source_html)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
[exerciseId, title, normalizedText, sourceHtml]
|
||||
[
|
||||
exerciseId,
|
||||
title,
|
||||
normalizedInstructionText || null,
|
||||
normalizedText,
|
||||
sourceHtml,
|
||||
]
|
||||
);
|
||||
|
||||
const fillGapExerciseId = fillGapExerciseResult.rows[0].id;
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("POST /api/admin/exercises/fillinthegap", () => {
|
||||
|
||||
const payload = {
|
||||
title: "Artikel im Kontext",
|
||||
instructionText: "Setze in jede Lücke das passende Wort ein.",
|
||||
text: "Ich gehe in die Schule und fahre mit dem Bus.",
|
||||
gaps: [
|
||||
{
|
||||
@@ -45,6 +46,7 @@ describe("POST /api/admin/exercises/fillinthegap", () => {
|
||||
);
|
||||
expect(exerciseRows.rows.length).toBe(1);
|
||||
expect(exerciseRows.rows[0].title).toBe(payload.title);
|
||||
expect(exerciseRows.rows[0].instruction_text).toBe(payload.instructionText);
|
||||
|
||||
const gapRows = await DB.pool(
|
||||
"SELECT * FROM fill_gap_gaps WHERE fill_gap_exercise_id = $1",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GET = withAuth(
|
||||
SELECT
|
||||
ffe.id,
|
||||
ffe.title,
|
||||
ffe.instruction_text,
|
||||
ffe.exercise_id,
|
||||
e.updated_at AS last_modified_at,
|
||||
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
|
||||
@@ -23,12 +24,8 @@ export const GET = withAuth(
|
||||
`,
|
||||
[exercise_id]
|
||||
);
|
||||
const parentExerciseId = rows[0].exercise_id;
|
||||
const createdBy = rows[0].created_by;
|
||||
|
||||
if (exerciseRows.length === 0) {
|
||||
if (!canDeleteOwnedContent(request.user, createdBy)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Exercise not found" },
|
||||
{ status: 404 }
|
||||
@@ -99,7 +96,8 @@ export const PUT = withAuth(
|
||||
try {
|
||||
const { exercise_id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, questions } = body;
|
||||
const { title, instructionText, questions } = body;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
|
||||
const hasValidAnswerBalance = questions.every(
|
||||
(q) =>
|
||||
@@ -131,10 +129,10 @@ export const PUT = withAuth(
|
||||
await tx.query(
|
||||
`
|
||||
UPDATE free_form_exercises
|
||||
SET title = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
SET title = $1, instruction_text = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`,
|
||||
[title, exercise_id]
|
||||
[title, normalizedInstructionText || null, exercise_id]
|
||||
);
|
||||
|
||||
await tx.query(
|
||||
|
||||
@@ -7,7 +7,8 @@ import { NextResponse } from "next/server";
|
||||
export const POST = withAuth(
|
||||
withInputValidation(freeFormExerciseSchema, async (req) => {
|
||||
const body = await req.json();
|
||||
const { title, questions } = body;
|
||||
const { title, instructionText, questions } = body;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
|
||||
const hasValidAnswerBalance = questions.every(
|
||||
(q) =>
|
||||
@@ -41,11 +42,11 @@ export const POST = withAuth(
|
||||
const exerciseId = exercise.rows[0].id;
|
||||
const freeFormExercise = await tx.query(
|
||||
`
|
||||
INSERT INTO free_form_exercises (exercise_id, title)
|
||||
VALUES ($1, $2)
|
||||
INSERT INTO free_form_exercises (exercise_id, title, instruction_text)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`,
|
||||
[exerciseId, title]
|
||||
[exerciseId, title, normalizedInstructionText || null]
|
||||
);
|
||||
|
||||
const freeFormExerciseId = freeFormExercise.rows[0].id;
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("POST /api/auth/admin/exercises/free-form", () => {
|
||||
|
||||
const validInput = {
|
||||
title: "European Capitals",
|
||||
instructionText: "Beantworte jede Frage in einem vollständigen Satz.",
|
||||
questions: [
|
||||
{
|
||||
question: "What is the capital of France?",
|
||||
@@ -54,6 +55,9 @@ describe("POST /api/auth/admin/exercises/free-form", () => {
|
||||
);
|
||||
expect(freeFormExerciseResult.rows.length).toBe(1);
|
||||
expect(freeFormExerciseResult.rows[0].title).toBe(validInput.title);
|
||||
expect(freeFormExerciseResult.rows[0].instruction_text).toBe(
|
||||
validInput.instructionText
|
||||
);
|
||||
|
||||
const questionRows = await DB.pool(
|
||||
"SELECT * FROM free_form_questions WHERE free_form_exercise_id = $1 ORDER BY question_order ASC",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
`SELECT
|
||||
jse.id,
|
||||
jse.title,
|
||||
jse.instruction_text,
|
||||
e.updated_at AS last_modified_at,
|
||||
COALESCE(NULLIF(u.username, ''), u.email) AS last_modified_by
|
||||
FROM jumbled_sentence_exercises jse
|
||||
@@ -57,12 +58,13 @@ export const PUT = withAuth(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { title, sentences } = parseResult.data;
|
||||
const { title, instructionText, sentences } = parseResult.data;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
try {
|
||||
await DB.transaction(async (client) => {
|
||||
await client.query(
|
||||
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[title, id]
|
||||
`UPDATE jumbled_sentence_exercises SET title = $1, instruction_text = $2, updated_at = NOW() WHERE id = $3`,
|
||||
[title, normalizedInstructionText || null, id]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE exercises
|
||||
|
||||
@@ -18,7 +18,8 @@ export const POST = withAuth(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { title, sentences } = parseResult.data;
|
||||
const { title, instructionText, sentences } = parseResult.data;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
const created_by = request.user.id;
|
||||
try {
|
||||
const result = await DB.transaction(async (client) => {
|
||||
@@ -28,8 +29,8 @@ export const POST = withAuth(
|
||||
);
|
||||
const exercise_id = exerciseRes.rows[0].id;
|
||||
const jumbledRes = await client.query(
|
||||
`INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3) RETURNING id`,
|
||||
[exercise_id, created_by, title]
|
||||
`INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title, instruction_text) VALUES (NOW(), NOW(), $1, $2, $3, $4) RETURNING id`,
|
||||
[exercise_id, created_by, title, normalizedInstructionText || null]
|
||||
);
|
||||
const jumbled_id = jumbledRes.rows[0].id;
|
||||
for (const s of sentences) {
|
||||
@@ -60,7 +61,7 @@ export const GET = withAuth(
|
||||
async () => {
|
||||
try {
|
||||
const res = await DB.pool(
|
||||
`SELECT id, title FROM jumbled_sentence_exercises ORDER BY id DESC`
|
||||
`SELECT id, title, instruction_text FROM jumbled_sentence_exercises ORDER BY id DESC`
|
||||
);
|
||||
return NextResponse.json({ exercises: res.rows });
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
|
||||
const payload = {
|
||||
title: "Satzreihenfolge A1",
|
||||
instructionText: "Ordnen Sie die Elemente zu einem korrekten Satz.",
|
||||
sentences: [
|
||||
{
|
||||
sentence: "Ich\nlerne jeden Tag\nDeutsch",
|
||||
@@ -36,11 +37,14 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
expect(json.id).toBeDefined();
|
||||
|
||||
const createdExercise = await DB.pool(
|
||||
"SELECT id, exercise_id, title, created_by FROM jumbled_sentence_exercises WHERE id = $1",
|
||||
"SELECT id, exercise_id, title, instruction_text, created_by FROM jumbled_sentence_exercises WHERE id = $1",
|
||||
[json.id]
|
||||
);
|
||||
expect(createdExercise.rows.length).toBe(1);
|
||||
expect(createdExercise.rows[0].title).toBe(payload.title);
|
||||
expect(createdExercise.rows[0].instruction_text).toBe(
|
||||
payload.instructionText
|
||||
);
|
||||
expect(createdExercise.rows[0].created_by).toBe(admin.id);
|
||||
|
||||
const linkedExercise = await DB.pool(
|
||||
@@ -105,14 +109,16 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
);
|
||||
|
||||
await DB.pool(
|
||||
"INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3), (NOW(), NOW(), $4, $5, $6)",
|
||||
"INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title, instruction_text) VALUES (NOW(), NOW(), $1, $2, $3, $4), (NOW(), NOW(), $5, $6, $7, $8)",
|
||||
[
|
||||
baseExerciseA.rows[0].id,
|
||||
admin.id,
|
||||
"Titel Eins",
|
||||
"Anweisung Eins",
|
||||
baseExerciseB.rows[0].id,
|
||||
admin.id,
|
||||
"Titel Zwei",
|
||||
"Anweisung Zwei",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -129,5 +135,9 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
const titles = json.exercises.map((item) => item.title);
|
||||
expect(titles).toContain("Titel Eins");
|
||||
expect(titles).toContain("Titel Zwei");
|
||||
|
||||
const instructions = json.exercises.map((item) => item.instruction_text);
|
||||
expect(instructions).toContain("Anweisung Eins");
|
||||
expect(instructions).toContain("Anweisung Zwei");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,8 @@ export const PUT = withAuth(
|
||||
try {
|
||||
const { exercise_id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, content } = body;
|
||||
const { title, instructionText, content } = body;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json(
|
||||
@@ -188,9 +189,11 @@ export const PUT = withAuth(
|
||||
|
||||
await tx.query(
|
||||
`UPDATE multichoice_exercises
|
||||
SET title = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[title, exercise_id]
|
||||
SET title = $1,
|
||||
instruction_text = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[title, normalizedInstructionText || null, exercise_id]
|
||||
);
|
||||
|
||||
await tx.query(
|
||||
|
||||
@@ -6,7 +6,8 @@ export const POST = withAuth(
|
||||
try {
|
||||
const json = await request.json();
|
||||
// We extract description but ignore it for the DB save
|
||||
const { title, content } = json;
|
||||
const { title, instructionText, content } = json;
|
||||
const normalizedInstructionText = String(instructionText || "").trim();
|
||||
|
||||
// Validation
|
||||
if (!title || !Array.isArray(content) || content.length === 0) {
|
||||
@@ -47,10 +48,10 @@ export const POST = withAuth(
|
||||
// 2. Insert into multichoice_exercises
|
||||
// FIX: Removed 'description' here too. Only saving exercise_id and title.
|
||||
const mcRes = await DB.pool(
|
||||
`INSERT INTO multichoice_exercises (exercise_id, title)
|
||||
VALUES ($1, $2)
|
||||
`INSERT INTO multichoice_exercises (exercise_id, title, instruction_text)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id`,
|
||||
[exercise_id, title]
|
||||
[exercise_id, title, normalizedInstructionText || null]
|
||||
);
|
||||
const multichoice_id = mcRes.rows[0].id;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("POST /api/admin/exercises/multichoice", () => {
|
||||
|
||||
const validInput = {
|
||||
title: "Where is Paris located?",
|
||||
instructionText: "Wähle für jede Lücke die passende Option aus.",
|
||||
description: "Geography question",
|
||||
content: [
|
||||
{
|
||||
@@ -48,6 +49,9 @@ describe("POST /api/admin/exercises/multichoice", () => {
|
||||
[exerciseId]
|
||||
);
|
||||
expect(multichoiceExerciseResult.rows.length).toBe(1);
|
||||
expect(multichoiceExerciseResult.rows[0].instruction_text).toBe(
|
||||
validInput.instructionText
|
||||
);
|
||||
|
||||
const content = await DB.pool(
|
||||
"SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1",
|
||||
|
||||
@@ -7,7 +7,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
const { exercise_id } = await params;
|
||||
|
||||
const { rows: exerciseRows } = await DB.pool(
|
||||
`SELECT id, title, source_text, source_html
|
||||
`SELECT id, title, instruction_text, source_text, source_html
|
||||
FROM fill_gap_exercises
|
||||
WHERE id = $1`,
|
||||
[exercise_id]
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
instruction_text,
|
||||
exercise_id
|
||||
FROM free_form_exercises
|
||||
WHERE id = $1
|
||||
|
||||
@@ -7,7 +7,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const exRes = await DB.pool(
|
||||
`SELECT id, title FROM jumbled_sentence_exercises WHERE id = $1`,
|
||||
`SELECT id, title, instruction_text FROM jumbled_sentence_exercises WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!exRes.rows.length) {
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function StudentExercisePage() {
|
||||
<WordSelectionExercise
|
||||
title={exercise.title}
|
||||
targetCategory={exercise.category}
|
||||
instructionText={exercise.category}
|
||||
targetWords={exercise.target_words}
|
||||
allWords={exercise.all_words}
|
||||
sourceHtml={exercise.source_html}
|
||||
|
||||
@@ -205,10 +205,11 @@ export default function FillInTheGapExercisePage() {
|
||||
return (
|
||||
<Container maxW="900px" m="0 auto" p="md">
|
||||
<Column gap="md">
|
||||
<h2>{exercise.title}</h2>
|
||||
<AdminVisibleLastModified
|
||||
endpoint={`/admin/exercises/fillinthegap/${exercise_id}`}
|
||||
/>
|
||||
<h2>{exercise.title}</h2>
|
||||
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
|
||||
|
||||
{!!exercise.source_html && (
|
||||
<Container
|
||||
|
||||
@@ -210,10 +210,11 @@ export default function FreeFormExercisePage() {
|
||||
return (
|
||||
<Container maxW="800px" m="0 auto" p="md">
|
||||
<Container mb="lg">
|
||||
<h2>{exercise.title}</h2>
|
||||
<AdminVisibleLastModified
|
||||
endpoint={`/admin/exercises/free-form/${exercise_id}`}
|
||||
/>
|
||||
<h2>{exercise.title}</h2>
|
||||
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
|
||||
</Container>
|
||||
|
||||
<Container mb="xl" p="md" bg="var(--bg2)" br="md">
|
||||
|
||||
@@ -286,10 +286,11 @@ export default function JumbledSentenceExercisePage() {
|
||||
|
||||
return (
|
||||
<Column gap="lg">
|
||||
<h2>{exercise.title}</h2>
|
||||
<AdminVisibleLastModified
|
||||
endpoint={`/admin/exercises/jumbled-sentence/${id}`}
|
||||
/>
|
||||
<h2>{exercise.title}</h2>
|
||||
{exercise.instruction_text && <p>{exercise.instruction_text}</p>}
|
||||
|
||||
{exercise.sentences.map((_, sentenceIdx) => (
|
||||
<Column
|
||||
|
||||
@@ -642,7 +642,7 @@ const WordSelectionExercise = ({
|
||||
let feedbackMessage = "";
|
||||
if (score === 100) {
|
||||
feedbackMessage =
|
||||
"Perfekt! Du hast alle " + targetCategory + " korrekt identifiziert!";
|
||||
"Perfekt! Du hast alle richtigen Wörter korrekt identifiziert!";
|
||||
} else if (score >= 90) {
|
||||
feedbackMessage = "Fast Perfekt! Punktzahl: " + score + "%";
|
||||
} else if (score >= 50) {
|
||||
|
||||
@@ -158,13 +158,15 @@ export default function MultichoicePage({ exerciseId }) {
|
||||
|
||||
// Check if all answers are correct
|
||||
const allCorrect = isSubmitted && checkedAnswers.every(Boolean);
|
||||
const instructionText =
|
||||
shuffledExerciseData.instruction_text ||
|
||||
shuffledExerciseData.exercise_description ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<div className="exercise-container">
|
||||
<h2 className="task-title">{shuffledExerciseData.title}</h2>
|
||||
<p className="task-description">
|
||||
{shuffledExerciseData.exercise_description}
|
||||
</p>
|
||||
{instructionText && <p className="task-description">{instructionText}</p>}
|
||||
|
||||
<RenderText
|
||||
exerciseData={shuffledExerciseData.content}
|
||||
|
||||
@@ -47,6 +47,11 @@ export const fillGapCreateSchema = z.object({
|
||||
.min(3, { message: "Title must be at least 3 characters" })
|
||||
.max(120, { message: "Title must be at most 120 characters" })
|
||||
),
|
||||
instructionText: trimmed(
|
||||
z
|
||||
.string()
|
||||
.max(500, { message: "Instruction must be at most 500 characters" })
|
||||
).optional(),
|
||||
text: normalized(z.string().min(1, { message: "Exercise text is required" })),
|
||||
textHtml: z.string().optional(),
|
||||
gaps: z
|
||||
|
||||
@@ -18,6 +18,10 @@ export const questionSchema = z.object({
|
||||
|
||||
export const freeFormExerciseSchema = z.object({
|
||||
title: z.string().min(1, { message: "Ein Titel ist erforderlich." }),
|
||||
instructionText: z
|
||||
.string()
|
||||
.max(500, { message: "Die Anweisung ist zu lang." })
|
||||
.optional(),
|
||||
questions: z
|
||||
.array(questionSchema)
|
||||
.min(1, { message: "Mindestens eine Frage ist erforderlich." }),
|
||||
|
||||
@@ -117,6 +117,7 @@ export const jumbledSentenceSchema = z
|
||||
|
||||
export const jumbledSentenceExerciseSchema = z.object({
|
||||
title: trimmed(z.string().min(3, { message: "Titel ist erforderlich." })),
|
||||
instructionText: trimmed(z.string().max(500)).optional(),
|
||||
sentences: z
|
||||
.array(jumbledSentenceSchema)
|
||||
.min(1, { message: "Mindestens ein Satz ist erforderlich." }),
|
||||
|
||||
Reference in New Issue
Block a user