Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
benutzen kann. jeder kann da probleme von der seite melden und
hinweisen. fotos mit schicken mit einem text. … so einfach wie
möglich, will nichts kompliziertes oder zu viel. kurz und knapp …
bei dogfather und der rechten hand soll die kachel anders gebaut sein
weil die sind die die sich um die probleme kümmern."
WARUM DAS NICHT DER VORHANDENE MELDEWEG IST. Es gibt ihn schon
(hilfe.html) und er sieht aehnlich aus. Drei Unterschiede machen ihn
zu etwas anderem:
1. Er ist NICHT fuer alle -- in rechte.js steht
`["gast","hand","admin"]`. Modis, Creator, Scouts, Manager und
die linke Hand hatten gar keinen Weg, einen kaputten Knopf zu
melden.
2. Er kann keine Bilder. Bei „der Knopf tut nichts" ist ein
Bildschirmfoto die halbe Antwort.
3. Er ist bewusst schwer: strikter Wechsel, hoechstens drei offene
Faelle, ein Betreff. Richtig bei einem Vorfall zwischen
Menschen, zu viel fuer „das Datum steht falsch da".
Deshalb eigen und sehr kurz: EIN Feld, EIN Bild, fertig. Kein
Betreff, keine Kategorie, keine Dringlichkeitsstufe -- wer ein
Formular mit fuenf Feldern sieht, meldet den kleinen Fehler nicht,
und genau die kleinen erfaehrt sonst niemand.
DIE LEITUNG SIEHT ETWAS ANDERES: einen Eingang mit Zahlenband
(neu / in Arbeit / erledigt), drei Filtern und zwei Handgriffen --
„Ich kuemmere mich" und „Erledigt …". Zwei, nicht fuenf; eine
Zuweisung an eine Person und Prioritaeten waeren ein Ticketsystem
fuer zwei Menschen, die nebeneinander sitzen. Das Melde-Feld bleibt
auch fuer sie da, rutscht aber unter den Eingang.
Wer zumacht, schreibt einen Satz dazu -- der Melder sieht nur diesen
Satz, und ohne ihn weiss er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Beide Seiten bekommen eine Benachrichtigung.
UEBERNOMMEN STATT NEU ERFUNDEN:
* `dateiErkennen` aus workspace-chat.js -- EXPORTIERT, nicht
abgeschrieben. An ihr haengt die ganze Sicherheit der Uploads
(der Typ kommt aus den ersten Bytes, nicht aus Name oder
Content-Type), und eine zweite Fassung waere die, die beim
naechsten Dateiformat vergessen wird.
* Die zweigeteilte Sicht und `meldungFuer()` (gibt den Datensatz
oder null zurueck, nie true/false) aus dem vertraulichen
Meldeweg.
* Der Upload-Weg (express.raw, Text im Kopf, Ordner unter
DATEN_ORDNER, damit die Sicherungspruefung ihn findet).
DIE KACHEL BEKOMMT JEDER -- an EINER Stelle angehaengt: `bereicheFuer`
haengt sie an jede Rollenliste, statt sie in fuenf Listen
einzutragen. Genau so ist heute der Benachrichtigungs-Knopf auf 14
Seiten verschwunden. Dazu der Eintrag in rechte.js (ohne ihn
verschwindet die Kachel lautlos, `nurOffeneKacheln` filtert dagegen)
und in der Browser-Kachelliste fuer die Agentur-Rollen.
TON 44 GERECHNET, NICHT GEWAEHLT -- und dabei zweimal danebengelegen:
* Ich habe erst einen eigenen Modus in kachel-farben.mjs gebaut.
`tools/kachel-farbe-einzeln.mjs` gibt es aber seit dem 22.09. fuer
genau diesen Zweck. Meine Dopplung ist wieder weg.
* Von Hand hatte ich #bcdbff eingetragen. pruef-kachelfarben lehnte
es ab (Buntheit 0,060, verlangt sind 0,12) -- und das vorhandene
Werkzeug verteidigte es trotzdem, weil sein Abstand gut war. Ein
Werkzeug, das einen regelwidrigen Zustand haelt, weil er zufaellig
gut misst, behebt genau den Fehler nicht, fuer den es da ist.
Behoben: Zuerst die Regeln, dann der Abstand. Ergebnis #fd6401,
Abstand 0,0914 (engstes vorhandenes Paar: 0,0154).
DREI MESSFEHLER VON MIR, die wie schwere Befunde aussahen: `fetch`
verwirft den Host-Kopf (die Community kommt nur auf crew. herein),
die Community bestaetigt ihr Alter (`alter_ok`), und die Startroute
heisst /api/ich. Alle drei stehen in pruef-treff.mjs richtig.
Ein ECHTER Fehler kam dazu: support.html lud bereiche.js nicht, und
kopf.js stuerzte mit „Cannot read properties of undefined (reading
'LEITUNG')" ab -- sichtbar nur in der Browserkonsole. Und
pruef-css-klassen fand sofort, dass auch wahl.js fehlte.
Gemessen: pruef-support 45 Pruefungen, 0 Fehler (alle neun Rollen
kommen herein, jede sieht die Kachel, als Bild getarntes HTML wird
abgelehnt, eine fremde Meldung bleibt fremd, erledigt bleibt
erledigt). Dazu 24 Messungen am Bild in drei Groessen. Gruen:
pruef-kachelfarben (22), pruef-css-klassen.
Co-Authored-By: Claude Opus 5 <[email protected]>
437 lines
18 KiB
JavaScript
437 lines
18 KiB
JavaScript
/* =====================================================================
|
||
DER SUPPORT (24.09.2026)
|
||
|
||
Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
|
||
benutzen kann. jeder kann da probleme von der seite melden und
|
||
hinweisen. fotos mit schicken mit einem text. es soll alles hoch
|
||
profissionell gemacht werden aber so einfach wie möglich, will
|
||
nichts kompliziertes oder zu viel. kurz und knapp … also bei
|
||
dogfather und der rechten hand soll die kachel anders gebaut sein
|
||
weil die sind die die sich um die probleme kümmern."
|
||
|
||
Die Regeln und ihre Begründung stehen im Kopf von
|
||
support-tabellen.js. Hier sind die Wege — und die vier Stellen, an
|
||
denen etwas schiefgehen kann:
|
||
|
||
1. EINE MELDUNG DARF NIE BEIM FALSCHEN LANDEN. Jede Leseroute geht
|
||
über `meldungFuer()`, das den Datensatz zurückgibt oder `null` —
|
||
nie `true`/`false`. So kann keine Route „darf sehen" beantworten
|
||
und danach trotzdem den falschen Datensatz laden. Dasselbe
|
||
Muster wie im vertraulichen Meldeweg.
|
||
|
||
2. DER TYP EINES BILDES KOMMT AUS DEM INHALT. `dateiErkennen()`
|
||
liest die ersten Bytes; Dateiname und Content-Type kommen vom
|
||
Absender und sind frei erfunden. Die Funktion wird aus
|
||
workspace-chat.js IMPORTIERT und nicht abgeschrieben: Eine
|
||
zweite Fassung wäre die, die beim nächsten Dateiformat vergessen
|
||
wird — und an ihr hängt die ganze Sicherheit der Uploads.
|
||
|
||
3. DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT.
|
||
Wer nicht angemeldet ist, kommt gar nicht bis zur Datei.
|
||
|
||
4. ERLEDIGT IST ERLEDIGT. Auch für die Leitung — eine geschlossene
|
||
Meldung wird nicht stillschweigend wieder geöffnet und anders
|
||
beantwortet.
|
||
===================================================================== */
|
||
|
||
import express from "express";
|
||
import { createReadStream, statSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { randomBytes } from "node:crypto";
|
||
import { db, protokolliere, DATEN_ORDNER, echteIp } from "./workspace.js";
|
||
import { SUPPORT_STAND, AUFBEWAHRUNG_TAGE } from "./support-tabellen.js";
|
||
import { dateiErkennen } from "./workspace-chat.js";
|
||
import { benachrichtige } from "./workspace-push.js";
|
||
|
||
export const supportRouter = express.Router();
|
||
|
||
/** Wer alle Meldungen sieht und sie bearbeitet.
|
||
*
|
||
* GENAU ZWEI ROLLEN, wie bestellt: DogFather und die rechte Hand.
|
||
* Dieselbe Menge wie im vertraulichen Meldeweg — und aus einem
|
||
* ähnlichen Grund: Wer sich kümmert, muss alles sehen; wer nur
|
||
* meldet, sieht seine eigene Meldung. */
|
||
const LEITUNG = new Set(["admin", "hand"]);
|
||
const istLeitung = (person) => LEITUNG.has(person?.rolle);
|
||
|
||
const TEXT_MAX = 2000;
|
||
const SEITE_MAX = 120;
|
||
const GERAET_MAX = 200;
|
||
const ANTWORT_MAX = 2000;
|
||
|
||
/** Zwölf Megabyte — dieselbe Grenze wie bei Chat-Anhängen.
|
||
* Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */
|
||
const BILD_MAX = 12 * 1024 * 1024;
|
||
|
||
/** Wie viele offene Meldungen eine Person gleichzeitig haben darf.
|
||
*
|
||
* Zehn, und damit deutlich großzügiger als die drei im vertraulichen
|
||
* Meldeweg. Dort ist die Grenze ein Schutz vor Überforderung auf
|
||
* beiden Seiten; hier wäre sie eine Bremse für genau die Person, die
|
||
* sich die Mühe macht, fünf kleine Fehler einzeln zu melden. Sie
|
||
* steht nur da, damit ein hängendes Skript nicht tausend Zeilen
|
||
* anlegt. */
|
||
const OFFEN_MAX = 10;
|
||
|
||
/* Der Ordner steht über join(DATEN_ORDNER, …) — genau dieses Muster
|
||
liest tools/wiederherstellung-proben.mjs aus, um zu prüfen, ob die
|
||
Sicherung ihn mitnimmt. Eine gepflegte Liste wäre die, die beim
|
||
nächsten Ordner vergessen wird. */
|
||
const BILD_ORDNER = join(DATEN_ORDNER, "support-bilder");
|
||
|
||
const jetzt = () => new Date().toISOString();
|
||
|
||
function sauber(s, max) {
|
||
return String(s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
|
||
}
|
||
|
||
/** Mehrzeiliger Text: Absätze bleiben, Leerzeilenwüsten nicht.
|
||
*
|
||
* Beim Melden schreibt man „Schritt 1 … Schritt 2 …" untereinander.
|
||
* Das über `\s+` plattzumachen wie einen Betreff würde genau die
|
||
* Struktur zerstören, die den Fehler nachvollziehbar macht. */
|
||
function textSauber(s, max) {
|
||
return String(s ?? "")
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/[ \t]+/g, " ")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim()
|
||
.slice(0, max);
|
||
}
|
||
|
||
/** Darf diese Person diese Meldung sehen?
|
||
*
|
||
* Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe
|
||
* Muster wie `fallFuer()` im vertraulichen Meldeweg, und aus
|
||
* demselben Grund. */
|
||
function meldungFuer(person, id) {
|
||
const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(Number(id));
|
||
if (!m) return null;
|
||
if (istLeitung(person)) return m;
|
||
return m.person_id === person?.id ? m : null;
|
||
}
|
||
|
||
/** Wie eine Meldung nach draußen aussieht.
|
||
*
|
||
* AN EINER STELLE, weil vier Routen sie ausgeben. Vier Stellen wären
|
||
* vier Gelegenheiten, ein Feld zu vergessen — oder eines zu viel
|
||
* mitzuschicken.
|
||
*
|
||
* `fuerLeitung` entscheidet nur über die Zusatzangaben, nicht über
|
||
* den Inhalt: Wer meldet, sieht seine eigene Meldung vollständig,
|
||
* auch den Namen, den er selbst trägt. */
|
||
function nachDraussen(m, fuerLeitung) {
|
||
return {
|
||
id: m.id,
|
||
text: m.text,
|
||
stand: m.stand,
|
||
antwort: m.antwort || null,
|
||
erstellt: m.erstellt,
|
||
geaendert: m.geaendert,
|
||
erledigt_am: m.erledigt_am || null,
|
||
seite: m.seite || null,
|
||
/* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt
|
||
jede Stelle im Browser denselben Pfad zusammen, und eine davon
|
||
setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
|
||
Chat-Anhang. */
|
||
bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null,
|
||
bild_breite: m.bild_breite || 0,
|
||
bild_hoehe: m.bild_hoehe || 0,
|
||
von: m.von_name || null,
|
||
von_rolle: m.von_rolle || null,
|
||
/* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet
|
||
wurde. Für den Melder ist es seine eigene Angabe und nur
|
||
Rauschen. */
|
||
geraet: fuerLeitung ? (m.geraet || null) : undefined,
|
||
};
|
||
}
|
||
|
||
/* =====================================================================
|
||
LESEN
|
||
===================================================================== */
|
||
|
||
/** Die eigenen Meldungen — oder für die Leitung alle.
|
||
*
|
||
* EINE ROUTE FÜR BEIDE, nicht zwei. Zwei Routen hießen zwei Stellen,
|
||
* an denen die Sichtbarkeitsregel gepflegt werden muss — und eine
|
||
* davon läuft irgendwann auseinander. */
|
||
supportRouter.get("/workspace/api/support", (req, res) => {
|
||
try {
|
||
const leitung = istLeitung(req.person);
|
||
const zeilen = leitung
|
||
? db().prepare(`SELECT * FROM support_meldungen ORDER BY
|
||
/* Offene zuerst, und darin die ältesten oben: Was am
|
||
längsten wartet, gehört nach vorn. Erledigte danach, das
|
||
Neueste zuerst. */
|
||
CASE stand WHEN 'neu' THEN 0 WHEN 'in_arbeit' THEN 1 ELSE 2 END,
|
||
CASE WHEN stand = 'erledigt' THEN -id ELSE id END
|
||
LIMIT 300`).all()
|
||
: db().prepare(`SELECT * FROM support_meldungen WHERE person_id = ?
|
||
ORDER BY id DESC LIMIT 100`).all(req.person.id);
|
||
|
||
res.json({
|
||
leitung,
|
||
meldungen: zeilen.map((m) => nachDraussen(m, leitung)),
|
||
/* Die Zahlen stehen in der Antwort und werden nicht im Browser
|
||
nachgezählt: Die Liste ist bei 300 abgeschnitten, eine Zählung
|
||
darauf wäre ab der 301. Meldung still falsch. */
|
||
offen: leitung
|
||
? db().prepare("SELECT COUNT(*) AS n FROM support_meldungen WHERE stand <> 'erledigt'")
|
||
.get().n
|
||
: db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
|
||
WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n,
|
||
hoechstens_bytes: BILD_MAX,
|
||
text_max: TEXT_MAX,
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[support] Liste:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Das Bild einer Meldung.
|
||
*
|
||
* Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der
|
||
* ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie,
|
||
* die dem Dokument jede Ausführung nimmt. */
|
||
supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => {
|
||
try {
|
||
const m = meldungFuer(req.person, req.params.id);
|
||
if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
const pfad = join(BILD_ORDNER, m.bild_datei);
|
||
try { statSync(pfad); } catch {
|
||
return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
|
||
}
|
||
res.setHeader("Content-Type", m.bild_typ || "image/png");
|
||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
||
res.setHeader("Cache-Control", "private, max-age=86400");
|
||
createReadStream(pfad).pipe(res);
|
||
} catch (fehler) {
|
||
console.error("[support] Bild:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
MELDEN
|
||
===================================================================== */
|
||
|
||
/** Vor express.raw: Darf diese Person überhaupt melden?
|
||
*
|
||
* Angemeldet sein genügt — „die jeder sieht, jeder benutzen kann".
|
||
* Die Prüfung steht trotzdem VOR der Annahme des Rumpfes, sonst
|
||
* wanderten zwölf Megabyte durch die Leitung, bevor irgendjemand
|
||
* fragt, wer da schickt. */
|
||
function darfMelden(req, res, next) {
|
||
if (!req.person?.id) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||
const offen = db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
|
||
WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n;
|
||
if (offen >= OFFEN_MAX) {
|
||
return res.status(429).json({
|
||
fehler: `Du hast schon ${offen} offene Meldungen. Warte kurz ab, `
|
||
+ "bis sich jemand darum gekümmert hat.",
|
||
});
|
||
}
|
||
next();
|
||
}
|
||
|
||
/** Eine Meldung abschicken.
|
||
*
|
||
* EIN WEG FÜR BEIDES — mit Bild und ohne. Der Text reist im Kopf
|
||
* (`x-text`, prozentkodiert), das Bild im Rumpf. Zwei Routen zu
|
||
* bauen („/support" für Text, "/support/bild" für Bild) wäre die
|
||
* naheliegende Aufteilung und hätte einen Zustand dazwischen: eine
|
||
* Meldung, die schon existiert, während das Bild noch hochlädt —
|
||
* und bei einem Abbruch für immer ohne Bild dasteht.
|
||
*
|
||
* Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */
|
||
supportRouter.post("/workspace/api/support",
|
||
darfMelden,
|
||
express.raw({ type: "*/*", limit: BILD_MAX }),
|
||
(req, res) => {
|
||
let geschrieben = null;
|
||
try {
|
||
let text = "";
|
||
try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; }
|
||
text = textSauber(text, TEXT_MAX);
|
||
if (text.length < 5) {
|
||
return res.status(400).json({
|
||
fehler: "Schreib bitte kurz, was los ist – ein Satz reicht.",
|
||
});
|
||
}
|
||
|
||
let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0;
|
||
if (Buffer.isBuffer(req.body) && req.body.length) {
|
||
const erkannt = dateiErkennen(req.body);
|
||
/* NUR BILDER. dateiErkennen kennt auch PDF und Ton — hier geht
|
||
es um Bildschirmfotos, und alles andere wäre eine Tür, die
|
||
niemand bestellt hat. */
|
||
if (!erkannt || erkannt.art !== "bild") {
|
||
return res.status(415).json({
|
||
fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
|
||
});
|
||
}
|
||
mkdirSync(BILD_ORDNER, { recursive: true });
|
||
bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
|
||
writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
|
||
geschrieben = join(BILD_ORDNER, bildDatei);
|
||
bildTyp = erkannt.typ;
|
||
bildBreite = erkannt.breite || 0;
|
||
bildHoehe = erkannt.hoehe || 0;
|
||
}
|
||
|
||
const n = jetzt();
|
||
const info = db().prepare(`INSERT INTO support_meldungen
|
||
(person_id, von_name, von_rolle, text, seite, geraet,
|
||
bild_datei, bild_typ, bild_breite, bild_hoehe,
|
||
stand, erstellt, geaendert)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||
.run(req.person.id, req.person.name || null, req.person.rolle || null,
|
||
text, sauber(req.get("x-seite"), SEITE_MAX) || null,
|
||
sauber(req.get("user-agent"), GERAET_MAX) || null,
|
||
bildDatei, bildTyp, bildBreite, bildHoehe,
|
||
SUPPORT_STAND.neu, n, n);
|
||
const id = Number(info.lastInsertRowid);
|
||
|
||
protokolliere("support_gemeldet", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id} ${text.slice(0, 80)}`,
|
||
});
|
||
|
||
/* DIE LEITUNG ERFÄHRT ES SOFORT. Eine Meldung, die erst auffällt,
|
||
wenn jemand die Seite zufällig öffnet, ist keine Meldung,
|
||
sondern ein Zettel im Schrank.
|
||
|
||
NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
|
||
nachts. Die Ruhezeit gilt (siehe workspace-push.js). */
|
||
for (const p of db().prepare(
|
||
`SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')`).all()) {
|
||
benachrichtige(p.id, "support", {
|
||
titel: "Neue Support-Meldung",
|
||
text: text.slice(0, 120),
|
||
ziel: `/workspace/support.html?meldung=${id}`,
|
||
}).catch(() => { /* eine ausgefallene Benachrichtigung darf die
|
||
Meldung nicht verhindern */ });
|
||
}
|
||
|
||
const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(id);
|
||
res.status(201).json({ meldung: nachDraussen(frisch, istLeitung(req.person)) });
|
||
} catch (fehler) {
|
||
/* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst
|
||
sammelt der Ordner Bilder an, zu denen es keine Meldung gibt —
|
||
und niemand merkt es, weil nichts darauf zeigt. */
|
||
if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
|
||
console.error("[support] Melden:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
SICH KÜMMERN — nur die Leitung
|
||
===================================================================== */
|
||
|
||
/** Stand ändern und antworten.
|
||
*
|
||
* EINE ROUTE FÜR BEIDES, weil es in der Oberfläche eine Handlung ist:
|
||
* „erledigt, und zwar deshalb". Zwei Routen hießen zwei Anfragen, und
|
||
* bei der zweiten kann das Netz abbrechen — dann steht die Meldung
|
||
* auf „erledigt" ohne den Satz, der sie erklärt. */
|
||
supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => {
|
||
try {
|
||
if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?")
|
||
.get(Number(req.params.id));
|
||
if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
/* ERLEDIGT IST ERLEDIGT. Eine geschlossene Meldung wird nicht
|
||
stillschweigend wieder geöffnet und anders beantwortet — sonst
|
||
ist „zugemacht" eine Meinung und keine Tatsache. Dieselbe Regel
|
||
wie im vertraulichen Meldeweg. */
|
||
if (m.stand === SUPPORT_STAND.erledigt) {
|
||
return res.status(409).json({
|
||
fehler: "Diese Meldung ist erledigt. Für etwas Neues bitte eine neue.",
|
||
});
|
||
}
|
||
|
||
const wunsch = String(req.body?.stand || "");
|
||
if (!Object.values(SUPPORT_STAND).includes(wunsch)) {
|
||
return res.status(400).json({ fehler: "ungueltiger_stand" });
|
||
}
|
||
const antwort = textSauber(req.body?.antwort, ANTWORT_MAX);
|
||
/* WER ZUMACHT, SAGT WARUM. Nicht als Schikane: Der Melder sieht
|
||
nur, dass seine Sache zu ist — ohne einen Satz weiß er nicht, ob
|
||
sie behoben wurde oder ob niemand Lust hatte. */
|
||
if (wunsch === SUPPORT_STAND.erledigt && antwort.length < 3) {
|
||
return res.status(400).json({
|
||
fehler: "Schreib kurz dazu, was daraus geworden ist – der Melder sieht nur das.",
|
||
});
|
||
}
|
||
|
||
const n = jetzt();
|
||
db().prepare(`UPDATE support_meldungen
|
||
SET stand = ?, antwort = ?, geaendert = ?,
|
||
erledigt_am = ?, erledigt_von = ?
|
||
WHERE id = ?`)
|
||
.run(wunsch, antwort || m.antwort || null, n,
|
||
wunsch === SUPPORT_STAND.erledigt ? n : null,
|
||
wunsch === SUPPORT_STAND.erledigt ? req.person.id : null,
|
||
m.id);
|
||
|
||
protokolliere("support_bearbeitet", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${m.id} -> ${wunsch}`,
|
||
});
|
||
|
||
/* DER MELDER ERFÄHRT ES. Wer etwas meldet und nie wieder davon
|
||
hört, meldet beim nächsten Mal nichts mehr. */
|
||
if (m.person_id && m.person_id !== req.person.id) {
|
||
benachrichtige(m.person_id, "support", {
|
||
titel: wunsch === SUPPORT_STAND.erledigt
|
||
? "Deine Meldung ist erledigt" : "Jemand kümmert sich",
|
||
text: antwort ? antwort.slice(0, 120) : m.text.slice(0, 120),
|
||
ziel: `/workspace/support.html?meldung=${m.id}`,
|
||
}).catch(() => { /* siehe oben */ });
|
||
}
|
||
|
||
const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id);
|
||
res.json({ meldung: nachDraussen(frisch, true) });
|
||
} catch (fehler) {
|
||
console.error("[support] Bearbeiten:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
AUFRÄUMEN
|
||
===================================================================== */
|
||
|
||
/** Erledigte Meldungen nach der Aufbewahrungsfrist entfernen.
|
||
*
|
||
* Wird aus dem Rundgang aufgerufen, nicht beim Lesen — sonst hängt
|
||
* das Aufräumen davon ab, dass jemand die Seite öffnet, und auf einem
|
||
* ruhigen System wächst der Bilderordner still weiter.
|
||
*
|
||
* DIE DATEI GEHT MIT DER ZEILE. Ein Bild ohne Meldung zeigt auf
|
||
* nichts und niemand findet es je wieder. */
|
||
export function supportAufraeumen() {
|
||
try {
|
||
const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString();
|
||
const alt = db().prepare(`SELECT id, bild_datei FROM support_meldungen
|
||
WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze);
|
||
if (!alt.length) return 0;
|
||
for (const m of alt) {
|
||
if (m.bild_datei) {
|
||
try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ }
|
||
}
|
||
db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id);
|
||
}
|
||
console.log(`[support] ${alt.length} erledigte Meldungen aelter als `
|
||
+ `${AUFBEWAHRUNG_TAGE} Tage entfernt.`);
|
||
return alt.length;
|
||
} catch (fehler) {
|
||
console.error("[support] Aufraeumen:", fehler?.message);
|
||
return 0;
|
||
}
|
||
}
|