diff --git a/gradesa/.gitignore b/gradesa/.gitignore
index 2d05e3e..2d232e8 100644
--- a/gradesa/.gitignore
+++ b/gradesa/.gitignore
@@ -40,3 +40,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# playwright test results
+test-results/
+playwright-report/
diff --git a/gradesa/data/migrations/00057_create_backups_table.sql b/gradesa/data/migrations/00057_create_backups_table.sql
new file mode 100644
index 0000000..5ecb09b
--- /dev/null
+++ b/gradesa/data/migrations/00057_create_backups_table.sql
@@ -0,0 +1,16 @@
+-- 00057_create_backups_table.sql
+-- Simple backups table for snapshots of created/updated entities
+
+CREATE TABLE IF NOT EXISTS backups (
+ id SERIAL PRIMARY KEY,
+ entity_type VARCHAR(128) NOT NULL,
+ entity_id TEXT,
+ actor_id INTEGER,
+ checksum VARCHAR(64) NOT NULL,
+ payload BYTEA NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+-- Avoid storing identical snapshots for the same entity
+CREATE UNIQUE INDEX IF NOT EXISTS backups_entity_checksum_idx
+ ON backups(entity_type, entity_id, checksum);
diff --git a/gradesa/data/schema.sql b/gradesa/data/schema.sql
index 39dd7a6..5a022ec 100644
--- a/gradesa/data/schema.sql
+++ b/gradesa/data/schema.sql
@@ -63,6 +63,22 @@ CREATE TABLE public.anchors (
anchor_id text UNIQUE NOT NULL
);
+CREATE SEQUENCE public.backups_id_seq;
+
+CREATE TABLE public.backups (
+ id integer PRIMARY KEY NOT NULL DEFAULT nextval('backups_id_seq'::regclass),
+ entity_type character varying(128) NOT NULL,
+ entity_id text,
+ actor_id integer,
+ checksum character varying(64) NOT NULL,
+ payload bytea NOT NULL,
+ created_at timestamp with time zone DEFAULT now()
+);
+
+CREATE UNIQUE INDEX backups_entity_checksum_idx ON public.backups USING btree (entity_type, entity_id, checksum);
+
+ALTER SEQUENCE public.backups_id_seq OWNED BY public.backups.id;
+
CREATE TABLE public.click_answers (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
diff --git a/gradesa/package.json b/gradesa/package.json
index e7b048f..40e0fd9 100644
--- a/gradesa/package.json
+++ b/gradesa/package.json
@@ -1,10 +1,12 @@
{
"name": "gradesa",
+ "type": "module",
"version": "0.1.0",
"private": true,
"packageManager": "npm@10.2.4",
"scripts": {
"dev": "next dev --turbopack",
+ "test:backups": "node --experimental-specifier-resolution=node scripts/test_backups.mjs",
"build": "next build",
"start": "sh ./data/migrate.sh --skip-schema-gen && next start",
"start:standalone": "sh ./data/migrate.sh --skip-schema-gen && node .next/standalone/server.js",
diff --git a/gradesa/playwright.config.js b/gradesa/playwright.config.js
index 989430a..b87ace8 100644
--- a/gradesa/playwright.config.js
+++ b/gradesa/playwright.config.js
@@ -1,10 +1,10 @@
// @ts-check
-const { defineConfig, devices } = require("@playwright/test");
+import { defineConfig, devices } from "@playwright/test";
/**
* @see https://playwright.dev/docs/test-configuration
*/
-module.exports = defineConfig({
+export default defineConfig({
testDir: "./playwright/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
diff --git a/gradesa/playwright/e2e/advanced.spec.js b/gradesa/playwright/e2e/advanced.spec.js
index faa912d..76d8717 100644
--- a/gradesa/playwright/e2e/advanced.spec.js
+++ b/gradesa/playwright/e2e/advanced.spec.js
@@ -1,5 +1,5 @@
-const { test, expect } = require("@playwright/test");
-const { elementExists } = require("./utils/helpers");
+import { test, expect } from "@playwright/test";
+import { elementExists } from "./utils/helpers.js";
test.describe("Advanced Interactions", () => {
test("should display grammar content", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/auth.spec.js b/gradesa/playwright/e2e/auth.spec.js
index 9cb9178..4bc1a56 100644
--- a/gradesa/playwright/e2e/auth.spec.js
+++ b/gradesa/playwright/e2e/auth.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("should navigate to login page", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/click-exercise.spec.js b/gradesa/playwright/e2e/click-exercise.spec.js
index 0f5e673..0edac52 100644
--- a/gradesa/playwright/e2e/click-exercise.spec.js
+++ b/gradesa/playwright/e2e/click-exercise.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
async function gotoClickExerciseList(page) {
await page.goto("grammar/exercises/click", {
diff --git a/gradesa/playwright/e2e/components.spec.js b/gradesa/playwright/e2e/components.spec.js
index bb9c38f..680283b 100644
--- a/gradesa/playwright/e2e/components.spec.js
+++ b/gradesa/playwright/e2e/components.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Component Interactions", () => {
test("should display chapter navigation", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/dnd-match-exercise.spec.js b/gradesa/playwright/e2e/dnd-match-exercise.spec.js
index 1b8bc56..d758d71 100644
--- a/gradesa/playwright/e2e/dnd-match-exercise.spec.js
+++ b/gradesa/playwright/e2e/dnd-match-exercise.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
async function gotoDndMatchList(page) {
await page.goto("grammar/exercises/dnd-match", {
diff --git a/gradesa/playwright/e2e/dragdrop-exercise.spec.js b/gradesa/playwright/e2e/dragdrop-exercise.spec.js
index 3fd2510..726a706 100644
--- a/gradesa/playwright/e2e/dragdrop-exercise.spec.js
+++ b/gradesa/playwright/e2e/dragdrop-exercise.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
async function gotoDragdropExerciseList(page) {
await page.goto("grammar/exercises/dragdrop", {
diff --git a/gradesa/playwright/e2e/fillinthegap.spec.js b/gradesa/playwright/e2e/fillinthegap.spec.js
index 6aba7fb..949875e 100644
--- a/gradesa/playwright/e2e/fillinthegap.spec.js
+++ b/gradesa/playwright/e2e/fillinthegap.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
async function gotoFillInTheGapList(page) {
await page.goto("grammar/exercises/fillinthegap", {
diff --git a/gradesa/playwright/e2e/forms.spec.js b/gradesa/playwright/e2e/forms.spec.js
index f57c1ed..74fea42 100644
--- a/gradesa/playwright/e2e/forms.spec.js
+++ b/gradesa/playwright/e2e/forms.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Form Interactions", () => {
test("should navigate to register page", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/global-search.spec.js b/gradesa/playwright/e2e/global-search.spec.js
index d80004b..9b8889d 100644
--- a/gradesa/playwright/e2e/global-search.spec.js
+++ b/gradesa/playwright/e2e/global-search.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Global header search", () => {
test("should search and navigate to a page result", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/grammar.spec.js b/gradesa/playwright/e2e/grammar.spec.js
index 82c5d83..ca22ff7 100644
--- a/gradesa/playwright/e2e/grammar.spec.js
+++ b/gradesa/playwright/e2e/grammar.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
async function gotoWithRetry(page, url, options, retries = 1) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
diff --git a/gradesa/playwright/e2e/homepage.spec.js b/gradesa/playwright/e2e/homepage.spec.js
index ccc89a8..6e47337 100644
--- a/gradesa/playwright/e2e/homepage.spec.js
+++ b/gradesa/playwright/e2e/homepage.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Home page", () => {
test("should display the home page correctly", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/learning.spec.js b/gradesa/playwright/e2e/learning.spec.js
index 53e4ce0..e9d698d 100644
--- a/gradesa/playwright/e2e/learning.spec.js
+++ b/gradesa/playwright/e2e/learning.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Learning Page", () => {
test("should load the learning page", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/navigation.spec.js b/gradesa/playwright/e2e/navigation.spec.js
index b31abc7..706f11d 100644
--- a/gradesa/playwright/e2e/navigation.spec.js
+++ b/gradesa/playwright/e2e/navigation.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Navigation", () => {
test("should navigate to grammar communication page", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/page-analyzer.spec.js b/gradesa/playwright/e2e/page-analyzer.spec.js
index 3cde4c2..b968112 100644
--- a/gradesa/playwright/e2e/page-analyzer.spec.js
+++ b/gradesa/playwright/e2e/page-analyzer.spec.js
@@ -1,5 +1,5 @@
-const { test } = require("@playwright/test");
-const { elementExists } = require("./utils/helpers");
+import { test } from "@playwright/test";
+import { elementExists } from "./utils/helpers.js";
test.describe("Page Structure Analysis", () => {
test("analyze homepage structure", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/resources-detail.spec.js b/gradesa/playwright/e2e/resources-detail.spec.js
index a57b6a1..c1da38f 100644
--- a/gradesa/playwright/e2e/resources-detail.spec.js
+++ b/gradesa/playwright/e2e/resources-detail.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("Resources Detail Pages", () => {
test("should display chapter content", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/users-admin.spec.js b/gradesa/playwright/e2e/users-admin.spec.js
index 66feaa2..78068cc 100644
--- a/gradesa/playwright/e2e/users-admin.spec.js
+++ b/gradesa/playwright/e2e/users-admin.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require("@playwright/test");
+import { test, expect } from "@playwright/test";
test.describe("User Management", () => {
test("users page route responds", async ({ page }) => {
diff --git a/gradesa/playwright/e2e/utils/helpers.js b/gradesa/playwright/e2e/utils/helpers.js
index e532c9b..3321f11 100644
--- a/gradesa/playwright/e2e/utils/helpers.js
+++ b/gradesa/playwright/e2e/utils/helpers.js
@@ -14,9 +14,4 @@ const getTextContent = async (page, selector) => {
return await page.locator(selector).textContent();
};
-module.exports = {
- wait,
- takeScreenshot,
- elementExists,
- getTextContent,
-};
+export { wait, takeScreenshot, elementExists, getTextContent };
diff --git a/gradesa/src/app/admin/backups/backups.css b/gradesa/src/app/admin/backups/backups.css
new file mode 100644
index 0000000..ffcec5f
--- /dev/null
+++ b/gradesa/src/app/admin/backups/backups.css
@@ -0,0 +1,38 @@
+.preview-modal {
+ position: fixed;
+ top: 10%;
+ left: 50%;
+ transform: translateX(-50%);
+ /* Use app color variables so the modal follows light/dark mode */
+ background: var(--bg2, #fff);
+ color: var(--fg1, #111);
+ border: 1px solid var(--bg3, #ddd);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
+ padding: 16px;
+ z-index: 1000;
+ max-width: 90%;
+ width: 820px;
+ border-radius: 6px;
+}
+
+.preview-modal h3 {
+ margin: 0 0 8px 0;
+ color: var(--fg2, #333);
+}
+
+.preview-modal pre {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 12px;
+ background: var(--bg3, #f8f8f8);
+ padding: 8px;
+ border-radius: 4px;
+ border: 1px solid var(--bg4, #eee);
+}
+
+.preview-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.32);
+ z-index: 999;
+}
diff --git a/gradesa/src/app/admin/backups/page.js b/gradesa/src/app/admin/backups/page.js
new file mode 100644
index 0000000..ad460a9
--- /dev/null
+++ b/gradesa/src/app/admin/backups/page.js
@@ -0,0 +1,206 @@
+"use client";
+
+import { Column, Row } from "@/components/ui/layout/container";
+import useQuery from "@/shared/hooks/useQuery";
+import { useRequest } from "@/shared/hooks/useRequest";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { useIsSuperAdmin } from "@/context/user.context";
+import styles from "./backups.css";
+
+export default function BackupsPage() {
+ const isSuperAdmin = useIsSuperAdmin();
+ const {
+ data: backups,
+ error,
+ isLoading,
+ refetch,
+ } = useQuery("/admin/backups", undefined, {
+ enabled: isSuperAdmin,
+ });
+ const makeRequest = useRequest();
+ const [restoringId, setRestoringId] = useState(null);
+ const [previewId, setPreviewId] = useState(null);
+ const [previewPayload, setPreviewPayload] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+
+ if (!isSuperAdmin) {
+ return
Wird geladen...
;
+ }
+
+ if (isLoading) {
+ return Wird geladen...
;
+ }
+
+ if (error) {
+ return Fehler beim Laden der Backups
;
+ }
+
+ const backupItems = backups || [];
+
+ const handleRestore = async (id) => {
+ if (!confirm("Möchten Sie dieses Backup wirklich wiederherstellen?")) {
+ return;
+ }
+
+ try {
+ setRestoringId(id);
+ await makeRequest("/admin/backups", { id }, { method: "POST" });
+ await refetch();
+ alert("Backup erfolgreich wiederhergestellt.");
+ } catch (restoreError) {
+ console.error("Restore failed:", restoreError);
+ alert(restoreError.message || "Fehler beim Wiederherstellen des Backups");
+ } finally {
+ setRestoringId(null);
+ }
+ };
+
+ const openPreview = async (id) => {
+ setPreviewId(id);
+ setPreviewLoading(true);
+ setPreviewPayload(null);
+ try {
+ const resp = await makeRequest(`/admin/backups?id=${id}`, null, {
+ method: "GET",
+ });
+ const payload = resp?.data ?? resp;
+ setPreviewPayload(payload);
+ } catch (err) {
+ console.error("Failed to load backup payload:", err);
+ alert("Fehler beim Laden der Backup-Vorschau");
+ setPreviewId(null);
+ } finally {
+ setPreviewLoading(false);
+ }
+ };
+
+ const closePreview = () => {
+ setPreviewId(null);
+ setPreviewPayload(null);
+ };
+
+ const renderEntityLabel = (entityType) => {
+ switch (entityType) {
+ case "news_articles":
+ return "News-Artikel";
+ case "glossary_entries":
+ return "Glossareintrag";
+ default:
+ return entityType;
+ }
+ };
+
+ return (
+
+
+ Backups
+
+
+ {backupItems.length === 0 ? (
+ Es sind keine Backups vorhanden.
+ ) : (
+
+
+
+
+ | ID |
+ Typ |
+ Titel |
+
+ Entity ID
+ |
+
+ Erstellt von
+ |
+
+ Erstellungsdatum
+ |
+ Aktion |
+
+
+
+ {backupItems.map((backup) => (
+
+ | {backup.id} |
+
+ {renderEntityLabel(backup.entity_type)}
+ |
+ {backup.preview ?? "-"} |
+ {backup.entity_id ?? "-"} |
+ {backup.actor_id ?? "-"} |
+
+ {new Date(backup.created_at).toLocaleString("de-DE")}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {previewId && (
+
+
Backup Vorschau ({previewId})
+ {previewLoading ? (
+
Lädt...
+ ) : previewPayload ? (
+
+
{JSON.stringify(previewPayload, null, 2)}
+
+ ) : (
+
Keine Vorschau verfügbar.
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/gradesa/src/app/admin/create-exercise/dnd-match/page.js b/gradesa/src/app/admin/create-exercise/dnd-match/page.js
index 7080f64..e23f3d0 100644
--- a/gradesa/src/app/admin/create-exercise/dnd-match/page.js
+++ b/gradesa/src/app/admin/create-exercise/dnd-match/page.js
@@ -101,7 +101,9 @@ export default function DndMatchAdminPage() {
}
);
const data = await res.json();
- if (!res.ok) throw new Error(data.error || "Fehler beim Speichern.");
+ if (!res.ok) {
+ throw new Error(data.error || "Fehler beim Speichern.");
+ }
router.push("/grammar/exercises/dnd-match");
} else {
const res = await fetch(
@@ -113,7 +115,9 @@ export default function DndMatchAdminPage() {
}
);
const data = await res.json();
- if (!res.ok) throw new Error(data.error || "Fehler beim Erstellen.");
+ if (!res.ok) {
+ throw new Error(data.error || "Fehler beim Erstellen.");
+ }
router.push(`/grammar/exercises/dnd-match/${data.id}`);
}
} catch (err) {
diff --git a/gradesa/src/app/admin/create-exercise/jumbled-sentence/page.js b/gradesa/src/app/admin/create-exercise/jumbled-sentence/page.js
index 1e001d1..6b97e1d 100644
--- a/gradesa/src/app/admin/create-exercise/jumbled-sentence/page.js
+++ b/gradesa/src/app/admin/create-exercise/jumbled-sentence/page.js
@@ -299,7 +299,9 @@ export default function CreateJumbledSentenceExercise() {
}),
});
const data = await res.json();
- if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
+ if (!res.ok) {
+ throw new Error(data?.error || "Fehler beim Speichern.");
+ }
router.push(
isEditMode
? "/grammar/exercises/jumbled-sentence"
diff --git a/gradesa/src/app/admin/glossary/create/page.js b/gradesa/src/app/admin/glossary/create/page.js
index 9455276..8d3c172 100644
--- a/gradesa/src/app/admin/glossary/create/page.js
+++ b/gradesa/src/app/admin/glossary/create/page.js
@@ -165,6 +165,7 @@ export default function CreateGlossaryEntry() {
)}
Nur für Lehrer sichtbar
0)
+ return NextResponse.json({ restored: res.rows[0].id });
+ }
+ const ins = await DB.pool(
+ `INSERT INTO news_articles (title, content, is_teacher_only) VALUES ($1,$2,$3) RETURNING id`,
+ [payload.title, payload.content, payload.is_teacher_only || false]
+ );
+ return NextResponse.json({ restored: ins.rows[0].id });
+ }
+
+ if (payload.__entity_type === "glossary_entries") {
+ if (payload.id) {
+ const res = await DB.pool(
+ `UPDATE glossary SET word=$1, word_definition=$2, updated_at=now() WHERE id=$3 RETURNING id`,
+ [payload.word, payload.word_definition, payload.id]
+ );
+ if (res.rows.length > 0)
+ return NextResponse.json({ restored: res.rows[0].id });
+ }
+ const ins = await DB.pool(
+ `INSERT INTO glossary (word, word_definition) VALUES ($1,$2) RETURNING id`,
+ [payload.word, payload.word_definition]
+ );
+ return NextResponse.json({ restored: ins.rows[0].id });
+ }
+
+ return NextResponse.json(
+ { error: "Unsupported entity type" },
+ { status: 400 }
+ );
+ } catch (err) {
+ console.error(err);
+ return NextResponse.json({ error: "restore failed" }, { status: 500 });
+ }
+ }
+
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
+}
+
+export const GET = withAuth(handler, { requireAuth: true, requireAdmin: true });
+export const POST = withAuth(handler, {
+ requireAuth: true,
+ requireAdmin: true,
+});
diff --git a/gradesa/src/app/api/admin/exercises/click/[click_id]/route.js b/gradesa/src/app/api/admin/exercises/click/[click_id]/route.js
index c527fec..aa783d3 100644
--- a/gradesa/src/app/api/admin/exercises/click/[click_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/click/[click_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import DOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -262,6 +263,45 @@ export const DELETE = withAuth(
const exerciseId = rows[0].exercise_id;
+ // Snapshot the exercise before deletion
+ try {
+ const payloadRes = await tx.query(
+ `SELECT ce.id, ce.title, ce.category, ce.target_words, ce.all_words, ce.source_html
+ FROM click_exercises ce
+ WHERE ce.id = $1`,
+ [click_id]
+ );
+ const feedbackRes = await tx.query(
+ `SELECT slot_key, word_text, feedback FROM click_false_word_feedbacks WHERE click_exercise_id = $1 ORDER BY id ASC`,
+ [click_id]
+ );
+
+ const payload = payloadRes.rows[0]
+ ? {
+ ...payloadRes.rows[0],
+ false_word_feedbacks: feedbackRes.rows,
+ }
+ : null;
+
+ if (payload) {
+ try {
+ await saveBackup(
+ "click_exercises",
+ click_id,
+ payload,
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error(
+ "Failed to save backup before deleting click exercise:",
+ err
+ );
+ }
+ }
+ } catch (err) {
+ console.error("Failed to read click exercise for backup:", err);
+ }
+
await tx.query(
`DELETE FROM click_to_exercises
WHERE click_id = $1`,
diff --git a/gradesa/src/app/api/admin/exercises/click/route.js b/gradesa/src/app/api/admin/exercises/click/route.js
index 828aaed..21ae422 100644
--- a/gradesa/src/app/api/admin/exercises/click/route.js
+++ b/gradesa/src/app/api/admin/exercises/click/route.js
@@ -1,5 +1,6 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import DOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -155,6 +156,25 @@ export const POST = withAuth(
return clickExerciseId;
});
+ try {
+ await saveBackup(
+ "click_exercises",
+ id,
+ {
+ id,
+ title,
+ category: targetCategory ?? null,
+ target_words: targetWords,
+ all_words: allWords,
+ source_html: sanitizedSourceHtml,
+ false_word_feedbacks: normalizedFalseWordFeedbacks,
+ },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save click exercise backup:", err);
+ }
+
return Response.json({ id }, { status: 201 });
},
{
diff --git a/gradesa/src/app/api/admin/exercises/dnd-match/[match_id]/route.js b/gradesa/src/app/api/admin/exercises/dnd-match/[match_id]/route.js
index 7f2d521..a6d2be7 100644
--- a/gradesa/src/app/api/admin/exercises/dnd-match/[match_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/dnd-match/[match_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
export const GET = withAuth(
@@ -109,6 +110,27 @@ export const PUT = withAuth(
}
});
+ // Save snapshot after update
+ try {
+ const { rows: exerciseRows } = await DB.pool(
+ `SELECT id, title, description FROM dnd_match_exercises WHERE id = $1`,
+ [match_id]
+ );
+ const { rows: pairs } = await DB.pool(
+ `SELECT id, left_item, right_item, pair_order FROM dnd_match_pairs WHERE dnd_match_exercise_id = $1 ORDER BY pair_order ASC`,
+ [match_id]
+ );
+
+ await saveBackup(
+ "dnd_match_exercises",
+ match_id,
+ { ...(exerciseRows[0] || {}), pairs },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save dnd-match backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
if (error.message === "NOT_FOUND") {
@@ -151,6 +173,27 @@ export const DELETE = withAuth(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+ // Snapshot before deletion
+ try {
+ const { rows: exerciseRows } = await DB.pool(
+ `SELECT id, title, description FROM dnd_match_exercises WHERE id = $1`,
+ [match_id]
+ );
+ const { rows: pairs } = await DB.pool(
+ `SELECT id, left_item, right_item, pair_order FROM dnd_match_pairs WHERE dnd_match_exercise_id = $1 ORDER BY pair_order ASC`,
+ [match_id]
+ );
+
+ await saveBackup(
+ "dnd_match_exercises",
+ match_id,
+ { ...(exerciseRows[0] || {}), pairs },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save dnd-match backup before delete:", err);
+ }
+
// Deleting from exercises cascades to dnd_match_exercises → pairs → user_answers
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
rows[0].exercise_id,
diff --git a/gradesa/src/app/api/admin/exercises/dnd-match/route.js b/gradesa/src/app/api/admin/exercises/dnd-match/route.js
index 08f1d1f..af1493c 100644
--- a/gradesa/src/app/api/admin/exercises/dnd-match/route.js
+++ b/gradesa/src/app/api/admin/exercises/dnd-match/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
export const POST = withAuth(
@@ -51,6 +52,23 @@ export const POST = withAuth(
return { id: matchId, exerciseId };
});
+ try {
+ await saveBackup(
+ "dnd_match_exercises",
+ result.id,
+ {
+ id: result.id,
+ exercise_id: result.exerciseId,
+ title,
+ description,
+ pairs,
+ },
+ createdBy ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save dnd-match backup:", err);
+ }
+
return NextResponse.json(
{ id: result.id, exercise_id: result.exerciseId },
{ status: 201 }
diff --git a/gradesa/src/app/api/admin/exercises/dragdrop/[dnd_id]/route.js b/gradesa/src/app/api/admin/exercises/dragdrop/[dnd_id]/route.js
index 0015642..6e78aab 100644
--- a/gradesa/src/app/api/admin/exercises/dragdrop/[dnd_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/dragdrop/[dnd_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
const parseWords = (content) => [
...new Set(
@@ -233,6 +234,32 @@ export const PUT = withAuth(
}
});
+ // Save snapshot after update
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, description FROM dnd_exercises WHERE id = $1`,
+ [dnd_id]
+ );
+ const fieldsRes = await DB.pool(
+ `SELECT dc.id AS category_id, dc.category, dc.color, ARRAY_AGG(DISTINCT dw.word ORDER BY dw.word) AS words FROM word_category_mappings wcm JOIN dnd_categories dc ON dc.id = wcm.category_id JOIN draggable_words dw ON dw.id = wcm.word_id WHERE wcm.exercise_id = $1 GROUP BY dc.id, dc.category, dc.color ORDER BY dc.id`,
+ [dnd_id]
+ );
+ const fields = fieldsRes.rows.map((row) => ({
+ category: row.category,
+ color: row.color,
+ content: (row.words || []).join(", "),
+ }));
+
+ await saveBackup(
+ "dnd_exercises",
+ dnd_id,
+ { ...(exerciseRes.rows[0] || {}), fields },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save dragdrop backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
if (error.message === "DND_EXERCISE_NOT_FOUND") {
@@ -279,6 +306,32 @@ export const DELETE = withAuth(
const exerciseId = dndRes.rows[0].exercise_id;
+ // Snapshot before deletion
+ try {
+ const exerciseRes = await tx.query(
+ `SELECT id, title, description FROM dnd_exercises WHERE id = $1`,
+ [dnd_id]
+ );
+ const fieldsRes = await tx.query(
+ `SELECT dc.id AS category_id, dc.category, dc.color, ARRAY_AGG(DISTINCT dw.word ORDER BY dw.word) AS words FROM word_category_mappings wcm JOIN dnd_categories dc ON dc.id = wcm.category_id JOIN draggable_words dw ON dw.id = wcm.word_id WHERE wcm.exercise_id = $1 GROUP BY dc.id, dc.category, dc.color ORDER BY dc.id`,
+ [dnd_id]
+ );
+ const fields = fieldsRes.rows.map((row) => ({
+ category: row.category,
+ color: row.color,
+ content: (row.words || []).join(", "),
+ }));
+
+ await saveBackup(
+ "dnd_exercises",
+ dnd_id,
+ { ...(exerciseRes.rows[0] || {}), fields },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save dragdrop backup before delete:", err);
+ }
+
await tx.query(
`DELETE FROM dnd_exercises
WHERE id = $1`,
diff --git a/gradesa/src/app/api/admin/exercises/dragdrop/route.js b/gradesa/src/app/api/admin/exercises/dragdrop/route.js
index 8fd2fb4..36d6602 100644
--- a/gradesa/src/app/api/admin/exercises/dragdrop/route.js
+++ b/gradesa/src/app/api/admin/exercises/dragdrop/route.js
@@ -1,5 +1,6 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
const findOrCreateCategory = async (category, color) => {
const existingCategory = await DB.pool(
@@ -115,6 +116,17 @@ export const POST = withAuth(
}
}
+ try {
+ await saveBackup(
+ "dnd_exercises",
+ dnd_id,
+ { id: dnd_id, exercise_id, title, description, fields },
+ created_by || null
+ );
+ } catch (err) {
+ console.error("Failed to save dragdrop backup:", err);
+ }
+
return Response.json({
success: true,
exerciseId: exercise_id,
diff --git a/gradesa/src/app/api/admin/exercises/fillinthegap/[exercise_id]/route.js b/gradesa/src/app/api/admin/exercises/fillinthegap/[exercise_id]/route.js
index 9c047b2..b9041df 100644
--- a/gradesa/src/app/api/admin/exercises/fillinthegap/[exercise_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/fillinthegap/[exercise_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
import {
htmlToPlainText,
@@ -190,6 +191,40 @@ export const PUT = withAuth(
}
});
+ // After update: save a backup of the current state
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, instruction_text, source_text, source_html FROM fill_gap_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const gapRes = await DB.pool(
+ `SELECT id, token_index, token_text, gap_order FROM fill_gap_gaps WHERE fill_gap_exercise_id = $1 ORDER BY gap_order ASC`,
+ [exercise_id]
+ );
+ const gapIds = gapRes.rows.map((g) => g.id);
+ let answerRows = [];
+ if (gapIds.length > 0) {
+ const ans = await DB.pool(
+ `SELECT id, fill_gap_gap_id, answer, is_correct, feedback FROM fill_gap_answers WHERE fill_gap_gap_id = ANY($1::bigint[]) ORDER BY id ASC`,
+ [gapIds]
+ );
+ answerRows = ans.rows;
+ }
+ const gaps = gapRes.rows.map((gap) => ({
+ ...gap,
+ answers: answerRows.filter((a) => a.fill_gap_gap_id === gap.id),
+ }));
+
+ await saveBackup(
+ "fill_gap_exercises",
+ exercise_id,
+ { ...(exerciseRes.rows[0] || {}), gaps },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save fillinthegap backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
if (error.message === "Exercise not found") {
@@ -236,6 +271,40 @@ export const DELETE = withAuth(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+ // Snapshot before deletion
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, instruction_text, source_text, source_html FROM fill_gap_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const gapRes = await DB.pool(
+ `SELECT id, token_index, token_text, gap_order FROM fill_gap_gaps WHERE fill_gap_exercise_id = $1 ORDER BY gap_order ASC`,
+ [exercise_id]
+ );
+ const gapIds = gapRes.rows.map((g) => g.id);
+ let answerRows = [];
+ if (gapIds.length > 0) {
+ const ans = await DB.pool(
+ `SELECT id, fill_gap_gap_id, answer, is_correct, feedback FROM fill_gap_answers WHERE fill_gap_gap_id = ANY($1::bigint[]) ORDER BY id ASC`,
+ [gapIds]
+ );
+ answerRows = ans.rows;
+ }
+ const gaps = gapRes.rows.map((gap) => ({
+ ...gap,
+ answers: answerRows.filter((a) => a.fill_gap_gap_id === gap.id),
+ }));
+
+ await saveBackup(
+ "fill_gap_exercises",
+ exercise_id,
+ { ...(exerciseRes.rows[0] || {}), gaps },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save fillinthegap backup before delete:", err);
+ }
+
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
rows[0].exercise_id,
]);
diff --git a/gradesa/src/app/api/admin/exercises/fillinthegap/route.js b/gradesa/src/app/api/admin/exercises/fillinthegap/route.js
index 7538dfb..d1f7e45 100644
--- a/gradesa/src/app/api/admin/exercises/fillinthegap/route.js
+++ b/gradesa/src/app/api/admin/exercises/fillinthegap/route.js
@@ -1,5 +1,6 @@
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import { withInputValidation } from "@/backend/middleware/withInputValidation";
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
import {
@@ -85,6 +86,24 @@ export const POST = withAuth(
};
});
+ try {
+ await saveBackup(
+ "fill_gap_exercises",
+ created.id,
+ {
+ id: created.id,
+ exercise_id: created.exerciseId,
+ title,
+ instruction_text: normalizedInstructionText || null,
+ source_text: normalizedText,
+ gaps,
+ },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save fillinthegap backup:", err);
+ }
+
return NextResponse.json(
{
id: created.id,
diff --git a/gradesa/src/app/api/admin/exercises/free-form/[exercise_id]/route.js b/gradesa/src/app/api/admin/exercises/free-form/[exercise_id]/route.js
index 63bbe42..42c0a11 100644
--- a/gradesa/src/app/api/admin/exercises/free-form/[exercise_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/free-form/[exercise_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
export const GET = withAuth(
async (request, { params }) => {
@@ -196,6 +197,39 @@ export const PUT = withAuth(
}
});
+ // After update, fetch the current state and save a backup
+ try {
+ const { rows: exerciseRows } = await DB.pool(
+ `SELECT id, title, instruction_text FROM free_form_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const { rows: questionRows } = await DB.pool(
+ `SELECT id, question, question_order FROM free_form_questions WHERE free_form_exercise_id = $1 ORDER BY question_order ASC`,
+ [exercise_id]
+ );
+ const questionIds = questionRows.map((q) => q.id);
+ let answerRows = [];
+ if (questionIds.length > 0) {
+ const res = await DB.pool(
+ `SELECT id, free_form_question_id, answer, is_correct, feedback FROM free_form_answers WHERE free_form_question_id = ANY($1::bigint[]) ORDER BY id ASC`,
+ [questionIds]
+ );
+ answerRows = res.rows;
+ }
+ const questions = questionRows.map((q) => ({
+ ...q,
+ answers: answerRows.filter((a) => a.free_form_question_id === q.id),
+ }));
+ await saveBackup(
+ "free_form_exercises",
+ exercise_id,
+ { ...(exerciseRows[0] || {}), questions },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save freeform backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating free form exercise:", error);
@@ -237,6 +271,40 @@ export const DELETE = withAuth(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+ // Snapshot before deletion
+ try {
+ const { rows: exerciseRows } = await DB.pool(
+ `SELECT id, title, instruction_text FROM free_form_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const { rows: questionRows } = await DB.pool(
+ `SELECT id, question, question_order FROM free_form_questions WHERE free_form_exercise_id = $1 ORDER BY question_order ASC`,
+ [exercise_id]
+ );
+ const questionIds = questionRows.map((q) => q.id);
+ let answerRows = [];
+ if (questionIds.length > 0) {
+ const res = await DB.pool(
+ `SELECT id, free_form_question_id, answer, is_correct, feedback FROM free_form_answers WHERE free_form_question_id = ANY($1::bigint[]) ORDER BY id ASC`,
+ [questionIds]
+ );
+ answerRows = res.rows;
+ }
+ const questions = questionRows.map((q) => ({
+ ...q,
+ answers: answerRows.filter((a) => a.free_form_question_id === q.id),
+ }));
+
+ await saveBackup(
+ "free_form_exercises",
+ exercise_id,
+ { ...(exerciseRows[0] || {}), questions },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save freeform backup before delete:", err);
+ }
+
await DB.pool(
`
DELETE FROM exercises
diff --git a/gradesa/src/app/api/admin/exercises/free-form/route.js b/gradesa/src/app/api/admin/exercises/free-form/route.js
index 535a21c..19b77e0 100644
--- a/gradesa/src/app/api/admin/exercises/free-form/route.js
+++ b/gradesa/src/app/api/admin/exercises/free-form/route.js
@@ -1,4 +1,5 @@
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
import { withInputValidation } from "@/backend/middleware/withInputValidation";
import { DB } from "@/backend/db";
import { freeFormExerciseSchema } from "@/shared/schemas/free-form.schemas";
@@ -84,6 +85,22 @@ export const POST = withAuth(
return exerciseId;
});
+ try {
+ await saveBackup(
+ "free_form_exercises",
+ exerciseId,
+ {
+ id: exerciseId,
+ title,
+ instruction_text: normalizedInstructionText || null,
+ questions,
+ },
+ req.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save freeform backup:", err);
+ }
+
return NextResponse.json({ success: true, exercise_id: exerciseId });
}),
{
diff --git a/gradesa/src/app/api/admin/exercises/jumbled-sentence/[id]/route.js b/gradesa/src/app/api/admin/exercises/jumbled-sentence/[id]/route.js
index ba4e987..78cdde0 100644
--- a/gradesa/src/app/api/admin/exercises/jumbled-sentence/[id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/jumbled-sentence/[id]/route.js
@@ -3,6 +3,7 @@ import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
@@ -95,6 +96,37 @@ export const PUT = withAuth(
);
}
});
+ // Save snapshot after update
+ try {
+ const exRes = await DB.pool(
+ `SELECT id, title, instruction_text FROM jumbled_sentence_exercises WHERE id = $1`,
+ [id]
+ );
+ const sRes = await DB.pool(
+ `SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
+ [id]
+ );
+ const sentences = sRes.rows.map((row) => ({
+ sentence: row.sentence,
+ alternates: row.alternates || [],
+ correctSentenceFeedback: row.correct_feedback || "",
+ alternateFeedbacks: row.alternate_feedbacks || [],
+ incorrectFeedbacks: row.incorrect_feedbacks || [],
+ incorrectAlternates: row.incorrect_alternates || [],
+ }));
+ await saveBackup(
+ "jumbled_sentence_exercises",
+ id,
+ { ...(exRes.rows[0] || {}), sentences },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error(
+ "Failed to save jumbled-sentence backup after update:",
+ err
+ );
+ }
+
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
@@ -124,6 +156,37 @@ export const DELETE = withAuth(
throw new Error("FORBIDDEN");
}
+ // Snapshot before deletion
+ try {
+ const exRes = await client.query(
+ `SELECT id, title, instruction_text FROM jumbled_sentence_exercises WHERE id = $1`,
+ [id]
+ );
+ const sRes = await client.query(
+ `SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
+ [id]
+ );
+ const sentences = sRes.rows.map((row) => ({
+ sentence: row.sentence,
+ alternates: row.alternates || [],
+ correctSentenceFeedback: row.correct_feedback || "",
+ alternateFeedbacks: row.alternate_feedbacks || [],
+ incorrectFeedbacks: row.incorrect_feedbacks || [],
+ incorrectAlternates: row.incorrect_alternates || [],
+ }));
+ await saveBackup(
+ "jumbled_sentence_exercises",
+ id,
+ { ...(exRes.rows[0] || {}), sentences },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error(
+ "Failed to save jumbled-sentence backup before delete:",
+ err
+ );
+ }
+
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
diff --git a/gradesa/src/app/api/admin/exercises/jumbled-sentence/route.js b/gradesa/src/app/api/admin/exercises/jumbled-sentence/route.js
index ad474ab..f920d16 100644
--- a/gradesa/src/app/api/admin/exercises/jumbled-sentence/route.js
+++ b/gradesa/src/app/api/admin/exercises/jumbled-sentence/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
export const POST = withAuth(
async (request) => {
@@ -49,6 +50,22 @@ export const POST = withAuth(
}
return { id: jumbled_id };
});
+ try {
+ await saveBackup(
+ "jumbled_sentence_exercises",
+ result.id,
+ {
+ id: result.id,
+ title,
+ instruction_text: normalizedInstructionText || null,
+ sentences,
+ },
+ created_by ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save jumbled-sentence backup:", err);
+ }
+
return NextResponse.json({ success: true, id: result.id });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
diff --git a/gradesa/src/app/api/admin/exercises/memory-game/[id]/route.js b/gradesa/src/app/api/admin/exercises/memory-game/[id]/route.js
index 3782937..34bc8f6 100644
--- a/gradesa/src/app/api/admin/exercises/memory-game/[id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/memory-game/[id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
export const GET = withAuth(
async (request, { params }) => {
@@ -125,6 +126,26 @@ export const PUT = withAuth(
}
});
+ // Save snapshot after update
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, description FROM memory_game_exercises WHERE id = $1`,
+ [id]
+ );
+ const pairsRes = await DB.pool(
+ `SELECT id, left_item, right_item, pair_order FROM memory_game_pairs WHERE memory_game_exercise_id = $1 ORDER BY pair_order ASC`,
+ [id]
+ );
+ await saveBackup(
+ "memory_game_exercises",
+ id,
+ { ...(exerciseRes.rows[0] || {}), pairs: pairsRes.rows },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save memory-game backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
if (error.message === "NOT_FOUND") {
@@ -174,6 +195,26 @@ export const DELETE = withAuth(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+ // Snapshot before deletion
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, description FROM memory_game_exercises WHERE id = $1`,
+ [id]
+ );
+ const pairsRes = await DB.pool(
+ `SELECT id, left_item, right_item, pair_order FROM memory_game_pairs WHERE memory_game_exercise_id = $1 ORDER BY pair_order ASC`,
+ [id]
+ );
+ await saveBackup(
+ "memory_game_exercises",
+ id,
+ { ...(exerciseRes.rows[0] || {}), pairs: pairsRes.rows },
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save memory-game backup before delete:", err);
+ }
+
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
rows[0].exercise_id,
]);
diff --git a/gradesa/src/app/api/admin/exercises/memory-game/route.js b/gradesa/src/app/api/admin/exercises/memory-game/route.js
index 5258dd8..2de144b 100644
--- a/gradesa/src/app/api/admin/exercises/memory-game/route.js
+++ b/gradesa/src/app/api/admin/exercises/memory-game/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
export const POST = withAuth(
async (request) => {
@@ -69,6 +70,17 @@ export const POST = withAuth(
return memoryGameId;
});
+ try {
+ await saveBackup(
+ "memory_game_exercises",
+ result,
+ { id: result, title, description, pairs },
+ createdBy ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save memory-game backup:", err);
+ }
+
return NextResponse.json({ id: result }, { status: 201 });
} catch (error) {
console.error("Error creating memory game exercise:", error);
diff --git a/gradesa/src/app/api/admin/exercises/multichoice/[exercise_id]/route.js b/gradesa/src/app/api/admin/exercises/multichoice/[exercise_id]/route.js
index 46bde69..967ab18 100644
--- a/gradesa/src/app/api/admin/exercises/multichoice/[exercise_id]/route.js
+++ b/gradesa/src/app/api/admin/exercises/multichoice/[exercise_id]/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
import { canDeleteOwnedContent } from "@/backend/content-permissions";
import { withAuth } from "@/backend/middleware/withAuth";
+import { saveBackup } from "@/backend/backups";
const validateContent = (content) => {
if (!Array.isArray(content) || content.length === 0) {
@@ -190,7 +191,7 @@ export const PUT = withAuth(
await tx.query(
`UPDATE multichoice_exercises
SET title = $1,
- instruction_text = $2,
+ exercise_description = $2,
updated_at = NOW()
WHERE id = $3`,
[title, normalizedInstructionText || null, exercise_id]
@@ -215,6 +216,56 @@ export const PUT = withAuth(
await saveContent(tx, exercise_id, content);
});
+ // After successful update, fetch the current state and save a backup
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, exercise_description AS instruction_text FROM multichoice_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const contentRes = await DB.pool(
+ `SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1 ORDER BY content_order`,
+ [exercise_id]
+ );
+ const content = contentRes.rows;
+
+ // Attach options
+ let options = [];
+ if (content.length > 0) {
+ const contentIds = content.map((c) => c.id);
+ const optionsRes = await DB.pool(
+ "SELECT * FROM multichoice_options WHERE multichoice_content_id = ANY($1::bigint[])",
+ [contentIds]
+ );
+ options = optionsRes.rows;
+ }
+
+ const contentWithOptions = content.map((item) => {
+ if (item.content_type === "multichoice") {
+ return {
+ ...item,
+ options: options
+ .filter((option) => option.multichoice_content_id === item.id)
+ .map((option) => option.option_value),
+ };
+ }
+ return item;
+ });
+
+ const payload = {
+ ...(exerciseRes.rows[0] || {}),
+ content: contentWithOptions,
+ };
+
+ await saveBackup(
+ "multichoice_exercises",
+ exercise_id,
+ payload,
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save multichoice backup after update:", err);
+ }
+
return NextResponse.json({ success: true });
} catch (error) {
if (error.code === "DUPLICATE_TITLE") {
@@ -268,6 +319,54 @@ export const DELETE = withAuth(
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+ // Snapshot the exercise before deletion
+ try {
+ const exerciseRes = await DB.pool(
+ `SELECT id, title, exercise_description AS instruction_text FROM multichoice_exercises WHERE id = $1`,
+ [exercise_id]
+ );
+ const contentRes = await DB.pool(
+ `SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1 ORDER BY content_order`,
+ [exercise_id]
+ );
+
+ let options = [];
+ if (contentRes.rows.length > 0) {
+ const contentIds = contentRes.rows.map((c) => c.id);
+ const optionsRes = await DB.pool(
+ "SELECT * FROM multichoice_options WHERE multichoice_content_id = ANY($1::bigint[])",
+ [contentIds]
+ );
+ options = optionsRes.rows;
+ }
+
+ const contentWithOptions = contentRes.rows.map((item) => {
+ if (item.content_type === "multichoice") {
+ return {
+ ...item,
+ options: options
+ .filter((option) => option.multichoice_content_id === item.id)
+ .map((option) => option.option_value),
+ };
+ }
+ return item;
+ });
+
+ const payload = {
+ ...(exerciseRes.rows[0] || {}),
+ content: contentWithOptions,
+ };
+
+ await saveBackup(
+ "multichoice_exercises",
+ exercise_id,
+ payload,
+ request.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Failed to save multichoice backup before delete:", err);
+ }
+
await DB.pool("DELETE FROM exercises WHERE id = $1", [
rows[0].exercise_id,
]);
diff --git a/gradesa/src/app/api/admin/exercises/multichoice/route.js b/gradesa/src/app/api/admin/exercises/multichoice/route.js
index 4cbc480..d7a56ec 100644
--- a/gradesa/src/app/api/admin/exercises/multichoice/route.js
+++ b/gradesa/src/app/api/admin/exercises/multichoice/route.js
@@ -1,4 +1,5 @@
import { DB } from "@/backend/db";
+import { saveBackup } from "@/backend/backups";
import { withAuth } from "@/backend/middleware/withAuth";
export const POST = withAuth(
@@ -48,7 +49,7 @@ 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, instruction_text)
+ `INSERT INTO multichoice_exercises (exercise_id, title, exercise_description)
VALUES ($1, $2, $3)
RETURNING id`,
[exercise_id, title, normalizedInstructionText || null]
@@ -123,6 +124,24 @@ export const POST = withAuth(
order++;
}
+ // Save a backup snapshot of the created multichoice exercise
+ try {
+ await saveBackup(
+ "multichoice_exercises",
+ multichoice_id,
+ {
+ id: multichoice_id,
+ exercise_id,
+ title,
+ instruction_text: normalizedInstructionText || null,
+ content,
+ },
+ created_by || null
+ );
+ } catch (err) {
+ console.error("Failed to save multichoice backup:", err);
+ }
+
// Return the ID as the slug fallback
return Response.json(
{
diff --git a/gradesa/src/app/api/admin/exercises/multichoice/route.test.js b/gradesa/src/app/api/admin/exercises/multichoice/route.test.js
index 304ec28..2063108 100644
--- a/gradesa/src/app/api/admin/exercises/multichoice/route.test.js
+++ b/gradesa/src/app/api/admin/exercises/multichoice/route.test.js
@@ -49,7 +49,7 @@ describe("POST /api/admin/exercises/multichoice", () => {
[exerciseId]
);
expect(multichoiceExerciseResult.rows.length).toBe(1);
- expect(multichoiceExerciseResult.rows[0].instruction_text).toBe(
+ expect(multichoiceExerciseResult.rows[0].exercise_description).toBe(
validInput.instructionText
);
diff --git a/gradesa/src/app/api/admin/glossary/route.js b/gradesa/src/app/api/admin/glossary/route.js
index aa89321..22f0b9b 100644
--- a/gradesa/src/app/api/admin/glossary/route.js
+++ b/gradesa/src/app/api/admin/glossary/route.js
@@ -1,6 +1,7 @@
import { withAuth } from "@/backend/middleware/withAuth";
import { withInputValidation } from "@/backend/middleware/withInputValidation";
import { DB } from "@/backend/db";
+import { saveBackup } from "@/backend/backups";
import { NextResponse } from "next/server";
import { glossaryEntrySchema } from "@/shared/schemas/glossary.schemas";
@@ -21,6 +22,18 @@ export const POST = withAuth(
return result.rows[0].id;
});
+ // Save a backup snapshot of the created entry
+ try {
+ await saveBackup(
+ "glossary_entries",
+ entryId,
+ { id: entryId, word, word_definition },
+ req.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Backup save error:", err);
+ }
+
return NextResponse.json({ success: true, entry_id: entryId });
}),
{
@@ -56,6 +69,18 @@ export const GET = withAuth(
);
}
+ // Save a backup snapshot of the updated entry
+ try {
+ await saveBackup(
+ "glossary_entries",
+ id,
+ result.rows[0],
+ req.user?.id ?? null
+ );
+ } catch (err) {
+ console.error("Backup save error:", err);
+ }
+
return NextResponse.json(result.rows[0]);
}
diff --git a/gradesa/src/app/api/admin/news/route.js b/gradesa/src/app/api/admin/news/route.js
index 09924b1..88bde12 100644
--- a/gradesa/src/app/api/admin/news/route.js
+++ b/gradesa/src/app/api/admin/news/route.js
@@ -1,4 +1,5 @@
import { DB } from "@/backend/db";
+import { saveBackup } from "@/backend/backups";
import { NextResponse } from "next/server";
export async function GET(request) {
@@ -82,6 +83,18 @@ export async function POST(request) {
[title, content, is_teacher_only || false]
);
+ // Save a backup snapshot
+ try {
+ await saveBackup(
+ "news_articles",
+ result.rows[0].id,
+ result.rows[0],
+ null
+ );
+ } catch (err) {
+ console.error("Backup save error:", err);
+ }
+
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
console.error("Error creating news article:", error);
@@ -118,6 +131,13 @@ export async function PUT(request) {
);
}
+ // Save a backup snapshot of the updated row (non-blocking)
+ try {
+ saveBackup("news_articles", id, result.rows[0], null);
+ } catch (err) {
+ console.error("Backup save error:", err);
+ }
+
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error("Error updating news article:", error);
diff --git a/gradesa/src/app/globals.css b/gradesa/src/app/globals.css
index 53899b0..dd3c939 100644
--- a/gradesa/src/app/globals.css
+++ b/gradesa/src/app/globals.css
@@ -434,6 +434,32 @@ label {
font-size: var(--font-sm);
}
+/* Form action area: keep buttons responsive and avoid overlapping inputs */
+.form-actions {
+ display: flex;
+ gap: var(--u-md);
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ margin-top: var(--u-xl);
+ margin-bottom: var(--u-xl);
+}
+
+.form-actions .baseButton {
+ min-width: 8rem;
+}
+
+@media (max-width: 640px) {
+ .form-actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .form-actions .baseButton {
+ width: 100% !important;
+ }
+}
+
.error {
color: var(--red);
}
diff --git a/gradesa/src/backend/backups.js b/gradesa/src/backend/backups.js
new file mode 100644
index 0000000..3a6b672
--- /dev/null
+++ b/gradesa/src/backend/backups.js
@@ -0,0 +1,103 @@
+import { DB } from "./db";
+import zlib from "zlib";
+import crypto from "crypto";
+
+export async function saveBackup(
+ entityType,
+ entityId,
+ payloadObject,
+ actorId = null
+) {
+ const body = Object.assign({}, payloadObject || {});
+ // embed metadata so restores know the original entity
+ body.__entity_type = entityType;
+ if (entityId !== undefined && entityId !== null) body.id = entityId;
+ const json = JSON.stringify(body);
+ const compressed = zlib.gzipSync(Buffer.from(json, "utf8"));
+ const checksum = crypto.createHash("md5").update(compressed).digest("hex");
+
+ try {
+ await DB.pool(
+ `INSERT INTO backups (entity_type, entity_id, actor_id, checksum, payload)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (entity_type, entity_id, checksum) DO NOTHING`,
+ [
+ entityType,
+ entityId ? String(entityId) : null,
+ actorId,
+ checksum,
+ compressed,
+ ]
+ );
+ } catch (err) {
+ console.error("Failed to save backup:", err);
+ }
+}
+
+export async function listBackups({
+ entityType = null,
+ entityId = null,
+ limit = 100,
+} = {}) {
+ const parts = [];
+ const params = [];
+ let idx = 1;
+ if (entityType) {
+ parts.push(`entity_type = $${idx++}`);
+ params.push(entityType);
+ }
+ if (entityId) {
+ parts.push(`entity_id = $${idx++}`);
+ params.push(String(entityId));
+ }
+
+ const where = parts.length ? `WHERE ${parts.join(" AND ")}` : "";
+ const q = `SELECT id, entity_type, entity_id, actor_id, checksum, created_at FROM backups ${where} ORDER BY created_at DESC LIMIT $${idx}`;
+ params.push(limit);
+ const res = await DB.pool(q, params);
+ // Attach a small preview extracted from the payload so the UI can display
+ // a human-friendly label without decompressing the full payload client-side.
+ const rows = res.rows;
+ const previews = await Promise.all(
+ rows.map(async (r) => {
+ try {
+ const payload = await getBackupPayload(r.id);
+ let preview = "";
+ if (payload) {
+ if (payload.title) preview = String(payload.title);
+ else if (payload.word) preview = String(payload.word);
+ else if (payload.word_definition)
+ preview = String(payload.word_definition);
+ else if (payload.content) {
+ // strip HTML tags for a short preview
+ const asText = String(payload.content).replace(/<[^>]*>/g, "");
+ preview = asText;
+ } else {
+ preview = JSON.stringify(payload);
+ }
+ }
+
+ if (preview && preview.length > 80)
+ preview = preview.slice(0, 77) + "...";
+ return { ...r, preview };
+ } catch (err) {
+ return { ...r, preview: "" };
+ }
+ })
+ );
+
+ return previews;
+}
+
+export async function getBackupPayload(id) {
+ const res = await DB.pool(`SELECT payload FROM backups WHERE id = $1`, [id]);
+ if (res.rows.length === 0) return null;
+ const buf = res.rows[0].payload;
+ try {
+ const json = zlib.gunzipSync(buf).toString("utf8");
+ return JSON.parse(json);
+ } catch (err) {
+ console.error("Failed to decompress backup payload:", err);
+ return null;
+ }
+}
diff --git a/gradesa/src/backend/middleware/withAuth.js b/gradesa/src/backend/middleware/withAuth.js
index b14be58..2ad3aa5 100644
--- a/gradesa/src/backend/middleware/withAuth.js
+++ b/gradesa/src/backend/middleware/withAuth.js
@@ -27,7 +27,7 @@ export function withAuth(callback, options = {}) {
if (!user && requireAuth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- if (requireAdmin && !user?.is_admin) {
+ if (requireAdmin && !(user?.is_admin || user?.is_superadmin)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
diff --git a/gradesa/src/components/ui/sidebar/sidebar.js b/gradesa/src/components/ui/sidebar/sidebar.js
index 0e07071..a28a19f 100644
--- a/gradesa/src/components/ui/sidebar/sidebar.js
+++ b/gradesa/src/components/ui/sidebar/sidebar.js
@@ -209,6 +209,12 @@ const adminSidebarLinks = [
link: "/admin/news",
id: "news",
},
+ {
+ title: "Backups",
+ link: "/admin/backups",
+ id: "backups",
+ superadminOnly: true,
+ },
{
title: "Benutzer",
link: "/admin/users",
@@ -232,7 +238,7 @@ const adminSidebarLinks = [
function AdminSideBar({ showUsers }) {
const links = showUsers
? adminSidebarLinks
- : adminSidebarLinks.filter((l) => l.id !== "users");
+ : adminSidebarLinks.filter((l) => l.id !== "users" && !l.superadminOnly);
const title = showUsers ? "Admin" : "Lehrer";
diff --git a/gradesa/test-results/.last-run.json b/gradesa/test-results/.last-run.json
deleted file mode 100644
index cbcc1fb..0000000
--- a/gradesa/test-results/.last-run.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "status": "passed",
- "failedTests": []
-}
\ No newline at end of file