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.

+ ) : ( +
+ + + + + + + + + + + + + + {backupItems.map((backup) => ( + + + + + + + + + + ))} + +
IDTypTitel + Entity ID + + Erstellt von + + Erstellungsdatum + Aktion
{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