Backups system

This commit is contained in:
strasmus1
2026-09-01 12:21:43 +00:00
parent 92d0c4d878
commit f661b4d8d0
33 changed files with 1189 additions and 8 deletions
@@ -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);
+16
View File
@@ -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(),
+2
View File
@@ -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",
+38
View File
@@ -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;
}
+206
View File
@@ -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 <p>Wird geladen...</p>;
}
if (isLoading) {
return <p>Wird geladen...</p>;
}
if (error) {
return <p className="error">Fehler beim Laden der Backups</p>;
}
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 (
<Column gap="md" width="100%">
<Row justify="space-between" gap="xl" width="100%" align="center">
<h2>Backups</h2>
</Row>
{backupItems.length === 0 ? (
<p>Es sind keine Backups vorhanden.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "12px" }}>ID</th>
<th style={{ textAlign: "left", padding: "12px" }}>Typ</th>
<th style={{ textAlign: "left", padding: "12px" }}>Titel</th>
<th style={{ textAlign: "left", padding: "12px" }}>
Entity ID
</th>
<th style={{ textAlign: "left", padding: "12px" }}>
Erstellt von
</th>
<th style={{ textAlign: "left", padding: "12px" }}>
Erstellungsdatum
</th>
<th style={{ textAlign: "left", padding: "12px" }}>Aktion</th>
</tr>
</thead>
<tbody>
{backupItems.map((backup) => (
<tr
key={backup.id}
style={{ borderTop: "1px solid var(--color-border, #ddd)" }}
>
<td style={{ padding: "12px" }}>{backup.id}</td>
<td style={{ padding: "12px" }}>
{renderEntityLabel(backup.entity_type)}
</td>
<td style={{ padding: "12px" }}>{backup.preview ?? "-"}</td>
<td style={{ padding: "12px" }}>{backup.entity_id ?? "-"}</td>
<td style={{ padding: "12px" }}>{backup.actor_id ?? "-"}</td>
<td style={{ padding: "12px" }}>
{new Date(backup.created_at).toLocaleString("de-DE")}
</td>
<td style={{ padding: "12px" }}>
<div style={{ display: "flex", gap: 8 }}>
<Button
size="sm"
variant="ghost"
onClick={() => openPreview(backup.id)}
>
Vorschau
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleRestore(backup.id)}
disabled={restoringId === backup.id}
>
{restoringId === backup.id
? "Wiederherstellung..."
: "Wiederherstellen"}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{previewId && (
<div className="preview-modal">
<h3>Backup Vorschau ({previewId})</h3>
{previewLoading ? (
<p>Lädt...</p>
) : previewPayload ? (
<div
style={{
maxHeight: "60vh",
overflowY: "auto",
whiteSpace: "pre-wrap",
}}
>
<pre>{JSON.stringify(previewPayload, null, 2)}</pre>
</div>
) : (
<p>Keine Vorschau verfügbar.</p>
)}
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary" onClick={closePreview}>
Vorschau schließen
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (confirm("Backup wirklich wiederherstellen?")) {
handleRestore(previewId);
closePreview();
}
}}
disabled={restoringId === previewId}
>
Wiederherstellen
</Button>
</div>
</div>
)}
</Column>
);
}
@@ -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) {
@@ -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"
@@ -165,6 +165,7 @@ export default function CreateGlossaryEntry() {
)}
</Column>
<Row
className="form-actions"
justify={"space-between"}
align="center"
wrap="wrap"
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import Editor from "@/components/ui/editor";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
import "../news.css";
const defaultFormErrors = {
title: "",
@@ -176,6 +177,7 @@ export default function CreateNewsArticle() {
<span>Nur für Lehrer sichtbar</span>
</label>
<Row
className="form-actions"
justify={"space-between"}
align="center"
wrap="wrap"
+104
View File
@@ -0,0 +1,104 @@
import { withAuth } from "@/backend/middleware/withAuth";
import { NextResponse } from "next/server";
import { listBackups, getBackupPayload } from "@/backend/backups";
import { DB } from "@/backend/db";
async function handler(request) {
if (request.method === "GET") {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (id) {
const payload = await getBackupPayload(id);
if (!payload)
return NextResponse.json(
{ error: "backup not found" },
{ status: 404 }
);
return NextResponse.json(payload);
}
const entityType = searchParams.get("entity_type");
const entityId = searchParams.get("entity_id");
const limit = parseInt(searchParams.get("limit") || "100", 10);
const rows = await listBackups({ entityType, entityId, limit });
return NextResponse.json(rows);
} catch (err) {
console.error("Error listing backups:", err);
return NextResponse.json(
{ error: "Failed to list backups", detail: String(err) },
{ status: 500 }
);
}
}
if (request.method === "POST") {
// Restore a backup by id
try {
const { id } = await request.json();
if (!id)
return NextResponse.json({ error: "id required" }, { status: 400 });
const payload = await getBackupPayload(id);
if (!payload)
return NextResponse.json(
{ error: "backup not found" },
{ status: 404 }
);
// Basic restore for known entity types: news_articles, glossary_entries
if (payload.__entity_type === "news_articles") {
// if entity id exists and row exists, update; else insert
if (payload.id) {
const res = await DB.pool(
`UPDATE news_articles SET title=$1, content=$2, is_teacher_only=$3, updated_at=now() WHERE id=$4 RETURNING id`,
[
payload.title,
payload.content,
payload.is_teacher_only || false,
payload.id,
]
);
if (res.rows.length > 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,
});
@@ -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`,
@@ -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 });
},
{
@@ -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,
@@ -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 }
@@ -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`,
@@ -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,
@@ -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,
]);
@@ -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,
@@ -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
@@ -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 });
}),
{
@@ -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]
@@ -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 });
@@ -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,
]);
@@ -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);
@@ -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,
]);
@@ -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(
{
@@ -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
);
@@ -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]);
}
+20
View File
@@ -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);
+26
View File
@@ -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);
}
+103
View File
@@ -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;
}
}
+1 -1
View File
@@ -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 });
}
+7 -1
View File
@@ -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";