mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Backups system
This commit is contained in:
@@ -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);
|
||||||
@@ -63,6 +63,22 @@ CREATE TABLE public.anchors (
|
|||||||
anchor_id text UNIQUE NOT NULL
|
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 (
|
CREATE TABLE public.click_answers (
|
||||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gradesa",
|
"name": "gradesa",
|
||||||
|
"type": "module",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@10.2.4",
|
"packageManager": "npm@10.2.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
"test:backups": "node --experimental-specifier-resolution=node scripts/test_backups.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "sh ./data/migrate.sh --skip-schema-gen && next start",
|
"start": "sh ./data/migrate.sh --skip-schema-gen && next start",
|
||||||
"start:standalone": "sh ./data/migrate.sh --skip-schema-gen && node .next/standalone/server.js",
|
"start:standalone": "sh ./data/migrate.sh --skip-schema-gen && node .next/standalone/server.js",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
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");
|
router.push("/grammar/exercises/dnd-match");
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -113,7 +115,9 @@ export default function DndMatchAdminPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
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}`);
|
router.push(`/grammar/exercises/dnd-match/${data.id}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -299,7 +299,9 @@ export default function CreateJumbledSentenceExercise() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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(
|
router.push(
|
||||||
isEditMode
|
isEditMode
|
||||||
? "/grammar/exercises/jumbled-sentence"
|
? "/grammar/exercises/jumbled-sentence"
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export default function CreateGlossaryEntry() {
|
|||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
<Row
|
<Row
|
||||||
|
className="form-actions"
|
||||||
justify={"space-between"}
|
justify={"space-between"}
|
||||||
align="center"
|
align="center"
|
||||||
wrap="wrap"
|
wrap="wrap"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import Editor from "@/components/ui/editor";
|
import Editor from "@/components/ui/editor";
|
||||||
import { withBasePath } from "@/shared/utils/basePath";
|
import { withBasePath } from "@/shared/utils/basePath";
|
||||||
import AdminLastModified from "@/components/ui/admin-last-modified";
|
import AdminLastModified from "@/components/ui/admin-last-modified";
|
||||||
|
import "../news.css";
|
||||||
|
|
||||||
const defaultFormErrors = {
|
const defaultFormErrors = {
|
||||||
title: "",
|
title: "",
|
||||||
@@ -176,6 +177,7 @@ export default function CreateNewsArticle() {
|
|||||||
<span>Nur für Lehrer sichtbar</span>
|
<span>Nur für Lehrer sichtbar</span>
|
||||||
</label>
|
</label>
|
||||||
<Row
|
<Row
|
||||||
|
className="form-actions"
|
||||||
justify={"space-between"}
|
justify={"space-between"}
|
||||||
align="center"
|
align="center"
|
||||||
wrap="wrap"
|
wrap="wrap"
|
||||||
|
|||||||
@@ -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 { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
@@ -262,6 +263,45 @@ export const DELETE = withAuth(
|
|||||||
|
|
||||||
const exerciseId = rows[0].exercise_id;
|
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(
|
await tx.query(
|
||||||
`DELETE FROM click_to_exercises
|
`DELETE FROM click_to_exercises
|
||||||
WHERE click_id = $1`,
|
WHERE click_id = $1`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
@@ -155,6 +156,25 @@ export const POST = withAuth(
|
|||||||
return clickExerciseId;
|
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 });
|
return Response.json({ id }, { status: 201 });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
|
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
|
||||||
|
|
||||||
export const GET = withAuth(
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "NOT_FOUND") {
|
if (error.message === "NOT_FOUND") {
|
||||||
@@ -151,6 +173,27 @@ export const DELETE = withAuth(
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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
|
// Deleting from exercises cascades to dnd_match_exercises → pairs → user_answers
|
||||||
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||||
rows[0].exercise_id,
|
rows[0].exercise_id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
|
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
@@ -51,6 +52,23 @@ export const POST = withAuth(
|
|||||||
return { id: matchId, exerciseId };
|
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(
|
return NextResponse.json(
|
||||||
{ id: result.id, exercise_id: result.exerciseId },
|
{ id: result.id, exercise_id: result.exerciseId },
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
const parseWords = (content) => [
|
const parseWords = (content) => [
|
||||||
...new Set(
|
...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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "DND_EXERCISE_NOT_FOUND") {
|
if (error.message === "DND_EXERCISE_NOT_FOUND") {
|
||||||
@@ -279,6 +306,32 @@ export const DELETE = withAuth(
|
|||||||
|
|
||||||
const exerciseId = dndRes.rows[0].exercise_id;
|
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(
|
await tx.query(
|
||||||
`DELETE FROM dnd_exercises
|
`DELETE FROM dnd_exercises
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
const findOrCreateCategory = async (category, color) => {
|
const findOrCreateCategory = async (category, color) => {
|
||||||
const existingCategory = await DB.pool(
|
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({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
exerciseId: exercise_id,
|
exerciseId: exercise_id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
|
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
|
||||||
import {
|
import {
|
||||||
htmlToPlainText,
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "Exercise not found") {
|
if (error.message === "Exercise not found") {
|
||||||
@@ -236,6 +271,40 @@ export const DELETE = withAuth(
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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`, [
|
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||||
rows[0].exercise_id,
|
rows[0].exercise_id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
||||||
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
|
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
|
||||||
import {
|
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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
id: created.id,
|
id: created.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
async (request, { params }) => {
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating free form exercise:", error);
|
console.error("Error updating free form exercise:", error);
|
||||||
@@ -237,6 +271,40 @@ export const DELETE = withAuth(
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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(
|
await DB.pool(
|
||||||
`
|
`
|
||||||
DELETE FROM exercises
|
DELETE FROM exercises
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { freeFormExerciseSchema } from "@/shared/schemas/free-form.schemas";
|
import { freeFormExerciseSchema } from "@/shared/schemas/free-form.schemas";
|
||||||
@@ -84,6 +85,22 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
return exerciseId;
|
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 });
|
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 { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
export const GET = withAuth(async (request, { params }) => {
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
const { id } = await 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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
@@ -124,6 +156,37 @@ export const DELETE = withAuth(
|
|||||||
throw new Error("FORBIDDEN");
|
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(
|
await client.query(
|
||||||
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
||||||
[id]
|
[id]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
async (request) => {
|
async (request) => {
|
||||||
@@ -49,6 +50,22 @@ export const POST = withAuth(
|
|||||||
}
|
}
|
||||||
return { id: jumbled_id };
|
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 });
|
return NextResponse.json({ success: true, id: result.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
async (request, { params }) => {
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "NOT_FOUND") {
|
if (error.message === "NOT_FOUND") {
|
||||||
@@ -174,6 +195,26 @@ export const DELETE = withAuth(
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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`, [
|
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||||
rows[0].exercise_id,
|
rows[0].exercise_id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
async (request) => {
|
async (request) => {
|
||||||
@@ -69,6 +70,17 @@ export const POST = withAuth(
|
|||||||
return memoryGameId;
|
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 });
|
return NextResponse.json({ id: result }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating memory game exercise:", error);
|
console.error("Error creating memory game exercise:", error);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
|
|
||||||
const validateContent = (content) => {
|
const validateContent = (content) => {
|
||||||
if (!Array.isArray(content) || content.length === 0) {
|
if (!Array.isArray(content) || content.length === 0) {
|
||||||
@@ -190,7 +191,7 @@ export const PUT = withAuth(
|
|||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE multichoice_exercises
|
`UPDATE multichoice_exercises
|
||||||
SET title = $1,
|
SET title = $1,
|
||||||
instruction_text = $2,
|
exercise_description = $2,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $3`,
|
WHERE id = $3`,
|
||||||
[title, normalizedInstructionText || null, exercise_id]
|
[title, normalizedInstructionText || null, exercise_id]
|
||||||
@@ -215,6 +216,56 @@ export const PUT = withAuth(
|
|||||||
await saveContent(tx, exercise_id, content);
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "DUPLICATE_TITLE") {
|
if (error.code === "DUPLICATE_TITLE") {
|
||||||
@@ -268,6 +319,54 @@ export const DELETE = withAuth(
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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", [
|
await DB.pool("DELETE FROM exercises WHERE id = $1", [
|
||||||
rows[0].exercise_id,
|
rows[0].exercise_id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
@@ -48,7 +49,7 @@ export const POST = withAuth(
|
|||||||
// 2. Insert into multichoice_exercises
|
// 2. Insert into multichoice_exercises
|
||||||
// FIX: Removed 'description' here too. Only saving exercise_id and title.
|
// FIX: Removed 'description' here too. Only saving exercise_id and title.
|
||||||
const mcRes = await DB.pool(
|
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)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[exercise_id, title, normalizedInstructionText || null]
|
[exercise_id, title, normalizedInstructionText || null]
|
||||||
@@ -123,6 +124,24 @@ export const POST = withAuth(
|
|||||||
order++;
|
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 the ID as the slug fallback
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("POST /api/admin/exercises/multichoice", () => {
|
|||||||
[exerciseId]
|
[exerciseId]
|
||||||
);
|
);
|
||||||
expect(multichoiceExerciseResult.rows.length).toBe(1);
|
expect(multichoiceExerciseResult.rows.length).toBe(1);
|
||||||
expect(multichoiceExerciseResult.rows[0].instruction_text).toBe(
|
expect(multichoiceExerciseResult.rows[0].exercise_description).toBe(
|
||||||
validInput.instructionText
|
validInput.instructionText
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { glossaryEntrySchema } from "@/shared/schemas/glossary.schemas";
|
import { glossaryEntrySchema } from "@/shared/schemas/glossary.schemas";
|
||||||
|
|
||||||
@@ -21,6 +22,18 @@ export const POST = withAuth(
|
|||||||
return result.rows[0].id;
|
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 });
|
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]);
|
return NextResponse.json(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { saveBackup } from "@/backend/backups";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
@@ -82,6 +83,18 @@ export async function POST(request) {
|
|||||||
[title, content, is_teacher_only || false]
|
[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 });
|
return NextResponse.json(result.rows[0], { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating news article:", 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]);
|
return NextResponse.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating news article:", error);
|
console.error("Error updating news article:", error);
|
||||||
|
|||||||
@@ -434,6 +434,32 @@ label {
|
|||||||
font-size: var(--font-sm);
|
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 {
|
.error {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export function withAuth(callback, options = {}) {
|
|||||||
if (!user && requireAuth) {
|
if (!user && requireAuth) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ const adminSidebarLinks = [
|
|||||||
link: "/admin/news",
|
link: "/admin/news",
|
||||||
id: "news",
|
id: "news",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Backups",
|
||||||
|
link: "/admin/backups",
|
||||||
|
id: "backups",
|
||||||
|
superadminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Benutzer",
|
title: "Benutzer",
|
||||||
link: "/admin/users",
|
link: "/admin/users",
|
||||||
@@ -232,7 +238,7 @@ const adminSidebarLinks = [
|
|||||||
function AdminSideBar({ showUsers }) {
|
function AdminSideBar({ showUsers }) {
|
||||||
const links = showUsers
|
const links = showUsers
|
||||||
? adminSidebarLinks
|
? adminSidebarLinks
|
||||||
: adminSidebarLinks.filter((l) => l.id !== "users");
|
: adminSidebarLinks.filter((l) => l.id !== "users" && !l.superadminOnly);
|
||||||
|
|
||||||
const title = showUsers ? "Admin" : "Lehrer";
|
const title = showUsers ? "Admin" : "Lehrer";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user