diff --git a/server/pruef-nachfrage.mjs b/server/pruef-nachfrage.mjs index f9e12ae4..d081409d 100644 --- a/server/pruef-nachfrage.mjs +++ b/server/pruef-nachfrage.mjs @@ -70,6 +70,14 @@ const CSS_ORDNER = join(SEITEN_ORDNER, "assets", "css"); Notnagel fuer Browser ohne . */ const DER_NOTNAGEL = "nachfrage.js"; +/** Ein winziges, gueltiges PNG (1x1) -- echte Kopfbytes, damit der + * Browser wirklich ein Bild bekommt und die Vorschau etwas anzeigen + * kann. Vorgetaeuschte Bytes ergaeben ein leeres ``, und die + * Pruefung haette „Vorschau da" gemeldet, ohne eine zu sehen. */ +const PNG_PROBE = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); + let ok = 0, fehl = 0; const pruef = (name, bedingung, zusatz = "") => { if (bedingung) { ok++; console.log(` OK ${name}`); } @@ -671,6 +679,204 @@ if (process.env.OHNE_BROWSER === "ja") { pruef("und der verworfene Text steht nirgends in der Liste", !inListe); } + /* ================================================================= + EIN BILD IM DIALOG (02.10.2026) + + VanVan (Support): „Wenn man hier im Support auf deine Frage 'geht + es wieder' reagiert und antwortet, kann man auch kein Bild + hinzufuegen." + + Das Feld ist eine OPTION dieses Dialogs, damit Grund und Bild in + denselben Kasten passen -- zwei Dialoge nacheinander hiessen: Wer + beim zweiten abbricht, hat den ersten umsonst getippt. + + GEPRUEFT WIRD DER MECHANISMUS, NICHT DIE SUPPORTSEITE. Der Dialog + wird hier direkt aufgerufen; dass die Supportseite ihn mit `bild` + aufruft, steht weiter oben in der Dateiprüfung. Beides zusammen + deckt den Weg ab, und keins von beidem braucht eine Meldung im + Zustand „wartet", die diese Pruefung erst herstellen muesste. + + DIE VORGABE IST AUS, und das ist die wichtigste Zeile hier: Ohne + sie bekaemen die 56 anderen Rueckfragen im Haus ein Bildfeld, + nach dem niemand gefragt hat. */ + console.log("\n --- Ein Bild im Dialog ---"); + { + /* OHNE `bild` DARF NICHTS DASTEHEN. */ + await seite.evaluate(() => { + window.__probe = window.frageNach({ titel: "Ohne Bild", grund: "Warum?" }); + }); + await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 }); + const ohne = await seite.evaluate(() => { + const z = document.querySelector('dialog.nachfrage [data-teil="bildzeile"]'); + return { da: !!z, versteckt: !!z && z.hidden }; + }); + pruef("ohne Angabe bleibt die Bildzeile verborgen", + ohne.da && ohne.versteckt); + await seite.click('dialog.nachfrage [data-teil="nein"]'); + await seite.waitForTimeout(400); + + /* MIT `bild` IST SIE DA -- und liefert die Datei zurueck. */ + await seite.evaluate(() => { + window.__probe = window.frageNach({ + titel: "Mit Bild", ja: "Senden", + grund: "Was geht noch nicht?", grundPflicht: true, grundMin: 3, + bild: "Bildschirmfoto dazu (freiwillig)", + }); + }); + await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 }); + const mit = await seite.evaluate(() => { + const z = document.querySelector('dialog.nachfrage [data-teil="bildzeile"]'); + const k = document.querySelector('dialog.nachfrage [data-teil="bildwaehlen"]'); + const r = k ? k.getBoundingClientRect() : null; + return { + sichtbar: !!z && !z.hidden, + schild: document.querySelector('dialog.nachfrage [data-teil="bildschild"]') + ?.textContent || "", + knopfHoch: r ? Math.round(r.height) : 0, + /* „Noch keins gewählt" statt einer leeren Zeile: Wer nichts + sieht, weiss nicht, ob er etwas vergessen hat. */ + name: document.querySelector('dialog.nachfrage [data-teil="bildname"]') + ?.textContent || "", + wegVerborgen: document.querySelector('dialog.nachfrage [data-teil="bildweg"]')?.hidden, + }; + }); + pruef("mit Angabe steht die Bildzeile da", mit.sichtbar); + pruef(` mit der Beschriftung aus dem Aufruf („${mit.schild}")`, + /Bildschirmfoto/.test(mit.schild)); + pruef(` der Knopf ist ${mit.knopfHoch} px hoch (Fingermass 44)`, + mit.knopfHoch >= 44); + pruef(` und es steht da, dass noch keins gewählt ist („${mit.name}")`, + /Noch keins/.test(mit.name)); + pruef(" der Entfernen-Knopf ist noch verborgen", mit.wegVerborgen === true); + + /* EIN BILD WAEHLEN -- mit einer echten Datei, nicht mit einem + vorgetaeuschten Ereignis. */ + await seite.setInputFiles("dialog.nachfrage #nachfrage-bild", { + name: "bildschirm.png", mimeType: "image/png", buffer: PNG_PROBE, + }); + await seite.waitForTimeout(400); + const gewaehlt = await seite.evaluate(() => { + const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]'); + return { + name: document.querySelector('dialog.nachfrage [data-teil="bildname"]') + ?.textContent || "", + wegDa: document.querySelector('dialog.nachfrage [data-teil="bildweg"]')?.hidden === false, + vorschau: !!schau && !schau.hidden && (schau.getAttribute("src") || "").length > 0, + hoch: schau ? Math.round(schau.getBoundingClientRect().height) : 0, + }; + }); + pruef(` nach der Wahl steht der Name da („${gewaehlt.name}")`, + /bildschirm\.png/.test(gewaehlt.name)); + pruef(" der Entfernen-Knopf erscheint", gewaehlt.wegDa); + /* WER SIEHT, WAS ER ANHAENGT, haengt seltener das falsche Bild an + -- dieselbe Ueberlegung wie beim Melden. */ + pruef(" und eine Vorschau ist da", gewaehlt.vorschau); + + /* ==== UND JETZT MIT EINEM HOHEN BILD ========================= + + Hier stand die Grenze zuerst am 1x1-Bild von oben gemessen: + „3 px, hoechstens 160". Das ist gruen und wertlos -- ein ein + Pixel hohes Bild kann keine Obergrenze ueberschreiten. Genau + die Sorte Haken, vor der die Hausregeln warnen. + + Ein hochkantes Bildschirmfoto vom Handy ist der echte Fall: + Ohne Deckel waere es hoeher als der ganze Dialog, und die + Knoepfe darunter waeren nicht mehr zu sehen. Das Bild entsteht + im Browser (300x900) und kommt als ECHTE Datei in das Feld -- + mit `DataTransfer`, nicht mit einem vorgetaeuschten Ereignis. */ + const hoch = await seite.evaluate(async () => { + const c = document.createElement("canvas"); + c.width = 300; c.height = 900; + const g = c.getContext("2d"); + g.fillStyle = "#2b6cb0"; g.fillRect(0, 0, c.width, c.height); + const blob = await new Promise((r) => c.toBlob(r, "image/png")); + const dt = new DataTransfer(); + dt.items.add(new File([blob], "hochkant.png", { type: "image/png" })); + const feld = document.querySelector("dialog.nachfrage #nachfrage-bild"); + feld.files = dt.files; + feld.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((r) => setTimeout(r, 300)); + const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]'); + const dlg = document.querySelector("dialog.nachfrage"); + const ja = document.querySelector('dialog.nachfrage [data-teil="ja"]'); + const r = schau.getBoundingClientRect(); + return { + echt: schau.naturalHeight, + gezeigt: Math.round(r.height), + breit: Math.round(r.width), + /* DER EIGENTLICHE PUNKT: Der Senden-Knopf muss noch im Fenster + stehen. Eine Obergrenze ist kein Selbstzweck. */ + jaImFenster: (() => { + const j = ja.getBoundingClientRect(); + return j.bottom <= window.innerHeight && j.height > 0; + })(), + dialogHoch: Math.round(dlg.getBoundingClientRect().height), + }; + }); + pruef(` ein 300x900 grosses Bild wird auf ${hoch.gezeigt} px gedeckelt` + + ` (echt ${hoch.echt}, höchstens 160)`, + hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 160); + pruef(` und es bleibt in der Breite (${hoch.breit} px)`, hoch.breit <= 300); + pruef(` der Senden-Knopf steht weiter im Fenster (Dialog ${hoch.dialogHoch} px)`, + hoch.jaImFenster); + + await seite.fill("dialog.nachfrage #nachfrage-grund", "Geht noch nicht, siehe Bild."); + await seite.click('dialog.nachfrage [data-teil="ja"]'); + await seite.waitForTimeout(500); + const erg = await seite.evaluate(async () => { + const a = await window.__probe; + return { + grund: a && a.grund, + bildName: a && a.bild ? a.bild.name : null, + bildTyp: a && a.bild ? a.bild.type : null, + istDatei: !!(a && a.bild && typeof a.bild.size === "number"), + }; + }); + pruef(`der Dialog gibt den Grund zurück („${erg.grund}")`, + /siehe Bild/.test(erg.grund || "")); + pruef(` und die Datei dazu (${erg.bildName}, ${erg.bildTyp})`, + /* und nicht : Zwei Zeilen + weiter oben wurde das Bild ERSETZT. Meine erste Fassung + erwartete noch das erste und wurde rot -- die Pruefung + hatte recht, die Erwartung war veraltet. */ + erg.istDatei && erg.bildName === "hochkant.png" && erg.bildTyp === "image/png"); + + /* GEGENPROBE: Abbrechen gibt gar nichts zurueck -- auch nicht das + Bild. Ohne diese Zeile koennte ein Abbruch trotzdem etwas + hochladen. */ + await seite.evaluate(() => { + window.__probe2 = window.frageNach({ + titel: "Abbrechen", grund: "Warum?", bild: "Bild dazu", + }); + }); + await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 }); + await seite.setInputFiles("dialog.nachfrage #nachfrage-bild", { + name: "egal.png", mimeType: "image/png", buffer: PNG_PROBE, + }); + await seite.waitForTimeout(300); + await seite.click('dialog.nachfrage [data-teil="nein"]'); + await seite.waitForTimeout(400); + const abbruch = await seite.evaluate(async () => await window.__probe2); + pruef("Gegenprobe: Abbrechen gibt nichts zurück, auch kein Bild", + abbruch === false); + + /* UND JEDES OEFFNEN FAENGT LEER AN. Ohne das haengt beim zweiten + Mal noch das Bild vom ersten dran -- und niemand sieht es, + weil der Dialog oben schon zu war. */ + await seite.evaluate(() => { + window.__probe3 = window.frageNach({ + titel: "Nochmal", grund: "Warum?", bild: "Bild dazu", + }); + }); + await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 }); + const frisch = await seite.evaluate(() => + document.querySelector('dialog.nachfrage [data-teil="bildname"]')?.textContent || ""); + pruef(` und beim nächsten Öffnen ist es leer („${frisch}")`, + /Noch keins/.test(frisch)); + await seite.click('dialog.nachfrage [data-teil="nein"]'); + await seite.waitForTimeout(300); + } + pruef("keine Abstürze im Browser", abstuerze.length === 0, abstuerze[0] || ""); await browser.close(); diff --git a/server/pruef-support.mjs b/server/pruef-support.mjs index 2a13a3e3..f63e2b79 100644 --- a/server/pruef-support.mjs +++ b/server/pruef-support.mjs @@ -465,8 +465,7 @@ melde("\n=== Sich kuemmern ==="); diese Zeile koennte sie ihre eigene Arbeit abnicken, und der ganze Umweg waere Zierde. */ const fremd = await hol("hand", `/workspace/api/support/${meldungMitBild}/rueckmeldung`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ geht: true }), + method: "POST", headers: { "x-geht": "ja" }, }); ok(fremd.status === 404, `Gegenprobe: die Leitung bestaetigt nicht fuer den Melder (${fremd.status})`); @@ -494,15 +493,15 @@ melde("\n=== Sich kuemmern ==="); /* ---- „Noch nicht" -- und ohne Wort geht das nicht ------------- */ const ohneWort = await hol("modi", `/workspace/api/support/${meldungMitBild}/rueckmeldung`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ geht: false }), + method: "POST", headers: { "x-geht": "nein" }, }); ok(ohneWort.status === 400, `„noch nicht" ohne Wort wird abgelehnt (${ohneWort.status})`); const zurueck = await hol("modi", `/workspace/api/support/${meldungMitBild}/rueckmeldung`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ geht: false, text: "Die Farbe ja, das Datum nicht." }), + method: "POST", + headers: { "x-geht": "nein", + "x-text": encodeURIComponent("Die Farbe ja, das Datum nicht.") }, }); ok(zurueck.status === 200, `mit Wort geht es zurueck (${zurueck.status})`); @@ -521,8 +520,7 @@ melde("\n=== Sich kuemmern ==="); ok(zu2.status === 200, `die Leitung antwortet ein zweites Mal (${zu2.status})`); const bestaetigt = await hol("modi", `/workspace/api/support/${meldungMitBild}/rueckmeldung`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ geht: true }), + method: "POST", headers: { "x-geht": "ja" }, }); ok(bestaetigt.status === 200, `der Melder bestaetigt (${bestaetigt.status})`); @@ -547,12 +545,124 @@ melde("\n=== Sich kuemmern ==="); sich eine geschlossene Sache durch ein zweites „noch nicht" wieder aufmachen -- „zugemacht" waere dann eine Meinung. */ const nochmal2 = await hol("modi", `/workspace/api/support/${meldungMitBild}/rueckmeldung`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ geht: false, text: "doch nicht" }), + method: "POST", + headers: { "x-geht": "nein", "x-text": encodeURIComponent("doch nicht") }, }); ok(nochmal2.status === 409, ` und der Melder kann sie auch nicht wieder aufmachen (${nochmal2.status})`); } + + /* =================================================================== + EIN BILD ZUR ANTWORT (02.10.2026) + + VanVan: „Wenn man hier im Support auf deine Frage 'geht es wieder' + reagiert und antwortet, kann man auch kein Bild hinzufuegen. Das + muesstest du auch noch hinzufuegen, damit man nochmal ein Bild + anhaengen kann, wenn das Problem noch besteht oder sich durch die + Aenderung ein neues Problem ergeben hat." + + Beim MELDEN gab es ein Bild von Anfang an, beim ANTWORTEN nicht. + + GEPRUEFT WIRD AN EINER NEUEN MELDUNG, nicht an der von oben: Die + ist erledigt und nimmt keine Antwort mehr an -- eine Pruefung, die + das uebersieht, misst eine 409 und haelt sie fuer einen Befund. + =================================================================== */ + melde("\n=== Ein Bild zur Antwort ==="); + { + const neu = await hol("modi", "/workspace/api/support", { + method: "POST", + headers: { "Content-Type": "text/plain", + "x-text": encodeURIComponent("Die Liste springt beim Scrollen.") }, + }); + /* Das Feld heisst -- nachgesehen, nicht geraten. + Mein erster Anlauf fragte und bekam ; die + Meldung war 201, der Fehler war meiner. */ + const neuId = (await neu.json())?.meldung?.id; + ok(neu.status === 201 && Number.isInteger(neuId), + `eine frische Meldung (${neu.status}, #${neuId})`); + + await hol("hand", `/workspace/api/support/${neuId}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt", antwort: "Probier es nochmal." }), + }); + + /* ---- Mit Bild zurueckgeben ----------------------------------- */ + const mitBild = await hol("modi", `/workspace/api/support/${neuId}/rueckmeldung`, { + method: "POST", + headers: { "Content-Type": "image/png", "x-geht": "nein", + "x-text": encodeURIComponent("Jetzt springt sie nach oben – siehe Bild.") }, + body: PNG, + }); + ok(mitBild.status === 200, `die Antwort geht mit Bild durch (${mitBild.status})`); + + const stand = await standVon("modi", neuId); + const r1 = (stand?.verlauf || [])[0]; + ok(!!r1?.urteil_bild, + `im Verlauf haengt das Bild an Runde ${r1?.nr} (${r1?.urteil_bild})`); + /* AN DER RUNDE UND NICHT AN DER MELDUNG. Sonst waere nach drei + Runden nicht mehr zu sehen, welches Bild zu welchem Versuch + gehoerte. */ + ok(/\/runde\/1\/bild$/.test(r1?.urteil_bild || ""), + " und zwar an DIESER Runde, nicht oben an der Meldung"); + ok(/noch nicht|nicht/.test(r1?.urteil || ""), ` das Urteil steht dabei (${r1?.urteil})`); + ok(/springt sie nach oben/.test(r1?.urteil_text || ""), + ` und der Text kam durch den Kopf heil an („${r1?.urteil_text}")`); + + /* ---- Das Bild kommt auch heraus ------------------------------ */ + const holen = await hol("modi", r1.urteil_bild); + const bytes = Buffer.from(await holen.arrayBuffer()); + ok(holen.status === 200 && bytes.length === PNG.length, + `der Melder bekommt es wieder (HTTP ${holen.status}, ${bytes.length} von ${PNG.length} Bytes)`); + ok(holen.headers.get("content-type") === "image/png", + `mit dem erkannten Typ (${holen.headers.get("content-type")})`); + /* Es geht ueber denselben Ausliefer-Weg wie alles andere seit + heute frueh -- also mit Teilanfragen. */ + ok(holen.headers.get("accept-ranges") === "bytes", + "und ueber den gemeinsamen Ausliefer-Weg (Accept-Ranges)"); + + const auchLeitung = await hol("hand", r1.urteil_bild); + ok(auchLeitung.status === 200, + `die Leitung sieht es auch (${auchLeitung.status}) — sie soll ja darauf schauen`); + + /* ---- GEGENPROBEN ---------------------------------------------- */ + const fremder = await hol("gast", r1.urteil_bild); + ok(fremder.status === 404, + `ein Fremder bekommt 404 (${fremder.status}) — nicht 403, sonst waere die Nummer verraten`); + + const ohneAnmeldung = await fetch(BASIS + r1.urteil_bild); + ok(ohneAnmeldung.status !== 200, + `ohne Anmeldung gar nichts (${ohneAnmeldung.status})`); + + const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/runde/9/bild`); + ok(gibtsNicht.status === 404, `eine Runde ohne Bild gibt 404 (${gibtsNicht.status})`); + + /* KEIN BILD IST WEITERHIN ERLAUBT -- ein Bildschirmfoto ist Hilfe, + keine Huerde. Ohne diese Zeile koennte die Pflicht hereinrutschen, + ohne dass es jemandem auffaellt. */ + await hol("hand", `/workspace/api/support/${neuId}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt", antwort: "Und jetzt?" }), + }); + const ohneBild = await hol("modi", `/workspace/api/support/${neuId}/rueckmeldung`, { + method: "POST", + headers: { "x-geht": "nein", "x-text": encodeURIComponent("Immer noch nicht.") }, + }); + ok(ohneBild.status === 200, `ohne Bild geht es genauso (${ohneBild.status})`); + + /* UND NUR BILDER. Eine PDF im Bildfeld waere eine Tuer, nach der + niemand gefragt hat -- dieselbe Absage wie beim Melden. */ + await hol("hand", `/workspace/api/support/${neuId}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt", antwort: "Und nun?" }), + }); + const alsPdf = await hol("modi", `/workspace/api/support/${neuId}/rueckmeldung`, { + method: "POST", + headers: { "Content-Type": "application/pdf", "x-geht": "nein", + "x-text": encodeURIComponent("Hier ein PDF.") }, + body: Buffer.from("%PDF-1.4\n%%EOF\n"), + }); + ok(alsPdf.status === 415, `eine PDF wird abgelehnt (${alsPdf.status})`); + } } /* ===================================================================== diff --git a/server/support-tabellen.js b/server/support-tabellen.js index 4aec5567..872d1a82 100644 --- a/server/support-tabellen.js +++ b/server/support-tabellen.js @@ -180,7 +180,32 @@ export function supportTabellen(d) { gueltiger Zustand, kein fehlender Wert. */ urteil TEXT CHECK (urteil IN ('geht','nicht')), urteil_text TEXT, - urteil_am TEXT + urteil_am TEXT, + /* ==== DAS BILD ZUR ANTWORT (02.10.2026) ==================== + + VanVan: „Wenn man hier im Support auf deine Frage 'geht es + wieder' reagiert und antwortet, kann man auch kein Bild + hinzufuegen. Das muesstest du auch noch hinzufuegen, damit + man nochmal ein Bild anhaengen kann, wenn das Problem noch + besteht oder sich durch die Aenderung ein neues Problem + ergeben hat." + + Beim MELDEN gab es ein Bild von Anfang an, beim ANTWORTEN + nicht. Dabei ist es dort mehr wert: „geht noch nicht" mit + Bildschirmfoto sagt in einer Sekunde, was drei Saetze nicht + treffen -- und ihr zweiter Satz nennt den Fall, an den ich + nicht gedacht haette: durch die Aenderung ein NEUES Problem. + + AN DER RUNDE UND NICHT AN DER MELDUNG. Die Meldung hat schon + ein Bild: das vom ersten Mal. Wuerde das hier ueberschrieben, + waere nach Runde drei nicht mehr zu sehen, womit es angefangen + hat -- genau die Frage, die einen wiederkehrenden Fehler + loest. Es ist dieselbe Ueberlegung, aus der es diese Tabelle + ueberhaupt gibt (siehe oben). */ + urteil_bild_datei TEXT, + urteil_bild_typ TEXT, + urteil_bild_breite INTEGER, + urteil_bild_hoehe INTEGER ); /* EINDEUTIG, NICHT NUR SCHNELL (25.09.2026). @@ -228,4 +253,41 @@ export function supportTabellen(d) { CREATE INDEX IF NOT EXISTS idx_support_person ON support_meldungen(person_id, id DESC); `); + + /* ==== NACHTRAGEN, WAS `IF NOT EXISTS` NICHT NACHTRAEGT ============ + (02.10.2026) + + Die Tabelle oben entsteht nur beim ERSTEN Mal. Auf einer + Datenbank, die es schon gibt -- also auf dem Server -- sieht + `CREATE TABLE IF NOT EXISTS` den Namen, findet ihn, und ist + fertig. Die vier neuen Spalten fuer das Bild zur Antwort kaemen + dort nie an: Lokal waere alles gruen, weil jede Pruefung ihre + Datenbank frisch anlegt, und live schluege das Schreiben fehl. + + GENAU DIESER FEHLER steht zwanzig Zeilen weiter oben schon einmal + beschrieben -- dort war es ein Index, der live nie eindeutig + wurde, weil `IF NOT EXISTS` nur den NAMEN sieht und nicht die + Bauart. Es ist dieselbe Falle, nur eine Tuer weiter. + + ABGELEITET STATT ABGESCHRIEBEN: Gefragt wird die Tabelle selbst + (`PRAGMA table_info`), nicht eine gepflegte Liste. Eine Liste, + die niemand pflegt, kann nicht veralten -- die am 11.09. hat + drei Spalten gekostet. */ + for (const [spalte, typ] of [ + ["urteil_bild_datei", "TEXT"], + ["urteil_bild_typ", "TEXT"], + ["urteil_bild_breite", "INTEGER"], + ["urteil_bild_hoehe", "INTEGER"], + ]) { + try { + const vorhanden = d.prepare("PRAGMA table_info(support_runden)").all() + .map((s) => s.name); + if (vorhanden.length && !vorhanden.includes(spalte)) { + d.exec(`ALTER TABLE support_runden ADD COLUMN ${spalte} ${typ}`); + console.log(`[support] Spalte '${spalte}' in support_runden ergaenzt.`); + } + } catch (fehler) { + console.error(`[support] Spalte '${spalte}':`, fehler?.message); + } + } } diff --git a/server/workspace-support.js b/server/workspace-support.js index b9f6b86b..9f3dec8f 100644 --- a/server/workspace-support.js +++ b/server/workspace-support.js @@ -191,12 +191,18 @@ function nachDraussen(m, fuerLeitung, werFragtId) { /** Der Verlauf einer Meldung -- eine Zeile je Runde. */ function verlaufVon(id) { - return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am + return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am, + urteil_bild_datei FROM support_runden WHERE meldung_id = ? ORDER BY nr`).all(id) .map((r) => ({ nr: r.nr, antwort: r.antwort, von: r.von_name || null, am: r.am, urteil: r.urteil || null, urteil_text: r.urteil_text || null, urteil_am: r.urteil_am || null, + /* DER WEG, NICHT DER DATEINAME. Wie beim Bild der Meldung eine + Zeile weiter oben: Was auf der Platte liegt, geht niemanden + etwas an, der nur ein Bild anzeigen will. */ + urteil_bild: r.urteil_bild_datei + ? `/workspace/api/support/${id}/runde/${r.nr}/bild` : null, })); } @@ -422,8 +428,29 @@ supportRouter.post("/workspace/api/support", Ein Muster im Pfad ist eine zweite Stelle, an der eine Zahl geprueft wird -- und die im Code ist die, die man liest. ===================================================================== */ +/* ==== EIN BILD DARF MIT (02.10.2026) =============================== + + VanVan: „Wenn man hier im Support auf deine Frage 'geht es wieder' + reagiert und antwortet, kann man auch kein Bild hinzufügen. Das + müsstest du auch noch hinzufügen, damit man nochmal ein Bild + anhängen kann, wenn das Problem noch besteht oder sich durch die + Änderung ein neues Problem ergeben hat." + + DERSELBE WEG WIE BEIM MELDEN, nicht ein zweiter: Text im Kopf + (`x-text`, prozentkodiert), Bild im Rumpf, eine Route für beides. + Die Begründung steht oben bei `POST /workspace/api/support` und + gilt hier genauso -- zwei Routen hätten einen Zustand dazwischen: + eine Antwort, die schon zählt, während das Bild noch lädt. + + `x-geht` STATT EINES FELDES IM RUMPF: Der Rumpf ist jetzt das Bild. + Alles, was keine Bilddatei ist, reist im Kopf -- sonst bräuchte es + doch wieder zwei Formate. + + EIN LEERER RUMPF IST ZULÄSSIG. Wer kein Bildschirmfoto hat, soll + trotzdem antworten können; ein Bild ist Hilfe, keine Hürde. */ supportRouter.post("/workspace/api/support/:id/rueckmeldung", - express.json(), (req, res) => { + express.raw({ type: "*/*", limit: BILD_MAX }), (req, res) => { + let geschrieben = null; try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); @@ -445,20 +472,49 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung", }); } - const geht = req.body?.geht === true; - const wort = textSauber(req.body?.text, ANTWORT_MAX); + const geht = String(req.get("x-geht") || "") === "ja"; + let roh = ""; + try { roh = decodeURIComponent(req.get("x-text") || ""); } catch { roh = ""; } + const wort = textSauber(roh, ANTWORT_MAX); if (!geht && wort.length < 3) { return res.status(400).json({ fehler: "Schreib kurz, was noch nicht geht – sonst fängt die Suche von vorn an.", }); } + /* DAS BILD -- dieselbe Erkennung und dieselbe Absage wie beim + Melden. `dateiErkennen` kennt auch PDF und Ton; hier geht es + um Bildschirmfotos, und alles andere wäre eine Tür, nach der + niemand gefragt hat. */ + let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0; + if (Buffer.isBuffer(req.body) && req.body.length) { + const erkannt = dateiErkennen(req.body); + 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 runde = m.runde || 1; db().prepare(`UPDATE support_runden - SET urteil = ?, urteil_text = ?, urteil_am = ? + SET urteil = ?, urteil_text = ?, urteil_am = ?, + urteil_bild_datei = COALESCE(?, urteil_bild_datei), + urteil_bild_typ = COALESCE(?, urteil_bild_typ), + urteil_bild_breite = COALESCE(?, urteil_bild_breite), + urteil_bild_hoehe = COALESCE(?, urteil_bild_hoehe) WHERE meldung_id = ? AND nr = ?`) - .run(geht ? "geht" : "nicht", wort || null, n, m.id, runde); + .run(geht ? "geht" : "nicht", wort || null, n, + bildDatei, bildTyp, bildDatei ? bildBreite : null, + bildDatei ? bildHoehe : null, m.id, runde); if (geht) { db().prepare(`UPDATE support_meldungen @@ -500,11 +556,50 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung", const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id); res.json({ meldung: nachDraussen(frisch, false, req.person.id) }); } catch (fehler) { + /* DIE DATEI WIEDER WEG, wenn das Schreiben danach scheitert -- + derselbe Handgriff wie beim Melden. Ohne ihn sammelt der + Bildordner Dateien an, zu denen es keine Zeile gibt, und + niemand kann sie je zuordnen. */ + if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } } console.error("[support] Rueckmeldung:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); +/* Das Bild zu EINER Runde. + * + * EIGENE ROUTE UND NICHT `/:id/bild` MIT ZUSATZ: Das Bild der + * Meldung und das einer Antwort sind zwei verschiedene Dinge mit + * verschiedenen Nummern. Eine Route, die mal das eine und mal das + * andere liefert, braucht beim Lesen jedes Mal einen Blick in den + * Quelltext. + * + * SICHTBARKEIT: dieselbe Regel wie beim Verlauf -- wer die Meldung + * sehen darf, sieht auch ihre Bilder. 404 und nicht 403, damit + * niemand an der Antwort ablesen kann, dass es die Meldung gibt. */ +supportRouter.get("/workspace/api/support/:id(\\d+)/runde/:nr(\\d+)/bild", (req, res) => { + try { + const m = meldungFuer(req.person, req.params.id); + if (!m) return res.status(404).json({ fehler: "nicht_gefunden" }); + const r = db().prepare(`SELECT urteil_bild_datei, urteil_bild_typ + FROM support_runden WHERE meldung_id = ? AND nr = ?`) + .get(m.id, Number(req.params.nr)); + if (!r || !r.urteil_bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" }); + const pfad = join(BILD_ORDNER, r.urteil_bild_datei); + try { statSync(pfad); } catch { + return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." }); + } + res.setHeader("Content-Type", r.urteil_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"); + liefereDatei(req, res, pfad); + } catch (fehler) { + console.error("[support] Rundenbild:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => { try { if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index b7ecf886..0bd54874 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +