diff --git a/server/index.js b/server/index.js index 8f8ec848..cba2fd90 100644 --- a/server/index.js +++ b/server/index.js @@ -54,6 +54,7 @@ import { teamlageRouter } from "./workspace-teamlage.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; import { videoRouter } from "./workspace-video.js"; +import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; import { icsRouter } from "./workspace-ics.js"; @@ -267,6 +268,7 @@ app.use(aufbewahrungRouter); app.use(auskunftRouter); app.use(uebernahmeRouter); app.use(befindenRouter); +app.use(bewerbungRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-bewerbung.mjs b/server/pruef-bewerbung.mjs new file mode 100644 index 00000000..5613d888 --- /dev/null +++ b/server/pruef-bewerbung.mjs @@ -0,0 +1,580 @@ +/* ===================================================================== + DIE MODI-ANFRAGE (17.09.2026) + + Filipe: „ich will dass die leute sich da anonym bei mir melden können + und eine anfrage machen können für modi ... und dan soll ich annehmen + ablehnen, in warteschlange stellen oder sofort kommunizieren können. + und wenn ich annehme dan soll diese person automatisch zu talente + kategorie weiter geleitet werden." + + --------------------------------------------------------------------- + WAS DIESE PRÜFUNG WIRKLICH ABSICHERN MUSS + + Nicht „kann man ein Formular abschicken" -- das sieht man. Sondern + die drei Zusagen, deren Bruch NIEMAND meldet: + + 1. VERTRAULICH. Hier stehen Alter, Schwächen und alte Sperren. Wer + das liest, entscheidet mit; wer es nicht lesen darf, darf auch + nicht erfahren, DASS es etwas gibt. Also 404, nicht 403 -- und + im Protokoll steht keine Zeile Inhalt. + + 2. DIE BRÜCKE TRÄGT GENAU EINMAL. „Annehmen" legt ein Talent an. + Zweimal angenommen (Doppelklick, Zurück-Taste, zwei Geräte) darf + kein zweites Talent ergeben -- sonst steht dieselbe Person + doppelt im Trichter, und das merkt man erst, wenn man sie + zweimal anspricht. + + 3. EINE ENTSCHEIDUNG OHNE SATZ IST KEINE. „Reden" und „Ablehnen" + verlangen eine Nachricht. Sie ist der einzige Weg, dieser Person + etwas zu sagen: Ein Zugang aus der Community steht ausdrücklich + nicht in den Chatlisten (AUSSEN_ROLLEN). + + --------------------------------------------------------------------- + WARUM node:http UND NICHT fetch -- siehe pruef-treff.mjs: Die + Community meldet sich an einer anderen Wand an, und welche Wand gilt, + entscheidet der Host-Kopf. `fetch` in Node setzt den selbst. + ===================================================================== */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4504, "pruef-bewerbung"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-bewerb-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(300_000, "pruef-bewerbung"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0, offen = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +/* Der dritte Ausgang: konnte nicht nachsehen. Mit Grund -- sonst weiss + man danach so wenig wie vorher. */ +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const fragen = await import("./workspace-bewerbung-fragen.js"); +const rollen = await import("./workspace-bewerbung-rollen.js"); + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Rieke", "hand", "CODE-HAND-0001"); +anlegen("Frida", "modi", "CODE-MODI-0001"); +const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001"); +const idBenno = anlegen("Benno", "gast", "CODE-GAST-0002"); +const idCora = anlegen("Cora", "gast", "CODE-GAST-0003"); +d.close(); + +function roh(pfad, host, kopf = {}, koerper = null, methode = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (koerper ? "POST" : "GET"), + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const alsJson = (r) => { try { return JSON.parse(r.text); } catch { return {}; } }; + +async function anmelden(rolle, code, host) { + const r = await roh("/workspace/api/anmelden", host, {}, + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); + const kekse = [].concat(r.kopf?.["set-cookie"] || []); + return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code }; +} + +const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW); +const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); +const kKessi = await anmelden("gast", "CODE-GAST-0001", CREW); +const kBenno = await anmelden("gast", "CODE-GAST-0002", CREW); +const kCora = await anmelden("gast", "CODE-GAST-0003", CREW); + +const holen = (pfad, k, host = CREW) => roh(pfad, host, { Cookie: k.keks }); +const schicken = (pfad, k, koerper, methode = "POST", host = CREW) => + roh(pfad, host, { Cookie: k.keks }, koerper, methode); + +/* Ein vollstaendig ausgefuellter Bogen -- die Pflichtliste kommt aus dem + Katalog und nicht aus einer Abschrift hier. Wer eine Pflichtfrage + ergaenzt, bekommt sie automatisch mit; eine Liste hier waere die, die + beim naechsten Mal fehlt. */ +function bogen(zusatz = {}) { + const a = {}; + for (const s of fragen.PFLICHT) a[s] = "Antwort auf " + s + " - lang genug fuer eine Pruefung."; + a.name = zusatz.name || "Kessi"; + return { ...a, ...zusatz }; +} + +/* ======================================================================= + 1. DER KATALOG + ======================================================================= */ +melde(""); +melde("=== 1. Der Fragebogen selbst ==="); +ok(fragen.BEWERBUNG_FRAGEN.length >= 12, + `der Bogen hat ${fragen.BEWERBUNG_FRAGEN.length} Fragen`); +ok(fragen.PFLICHT.length >= 6 && fragen.PFLICHT.length < fragen.BEWERBUNG_FRAGEN.length, + `davon ${fragen.PFLICHT.length} Pflicht – und nicht alle`); +{ + /* JEDE FRAGE GEHOERT IN EINE GRUPPE. Eine Frage ohne Gruppe wird von + der Oberflaeche stillschweigend nicht gezeichnet -- sie faellt + nicht auf, sie fehlt einfach. */ + const bekannt = new Set(fragen.BEWERBUNG_GRUPPEN.map((g) => g.schluessel)); + const heimatlos = fragen.BEWERBUNG_FRAGEN.filter((f) => !bekannt.has(f.gruppe)); + ok(heimatlos.length === 0, + heimatlos.length ? `ohne Gruppe: ${heimatlos.map((f) => f.schluessel).join(", ")}` + : "jede Frage steht in einer bekannten Gruppe"); + const leer = fragen.BEWERBUNG_GRUPPEN.filter( + (g) => !fragen.BEWERBUNG_FRAGEN.some((f) => f.gruppe === g.schluessel)); + ok(leer.length === 0, + leer.length ? `leere Gruppen: ${leer.map((g) => g.schluessel).join(", ")}` + : "und jede Gruppe hat Fragen"); +} +{ + const s = fragen.BEWERBUNG_FRAGEN.map((f) => f.schluessel); + ok(new Set(s).size === s.length, "die Schlüssel sind eindeutig"); +} +ok(fragen.BEWERBUNG_WEGE.length === 4, + `vier Wege: ${fragen.BEWERBUNG_WEGE.map((w) => w.name).join(" · ")}`); +ok(fragen.BEWERBUNG_WEGE[0].schluessel === "gespraech", + "„Reden“ steht vorne – die häufigste richtige Antwort ist eine Rückfrage"); +ok(fragen.BEWERBUNG_WEGE.filter((w) => w.weiter === "talent").length === 1, + "genau ein Weg führt zu den Talenten"); + +/* ======================================================================= + 2. WER DEN BOGEN ÜBERHAUPT BEKOMMT + ======================================================================= */ +melde(""); +melde("=== 2. Wer ihn bekommt ==="); +{ + const a = alsJson(await holen("/workspace/api/bewerbung", kKessi)); + ok(a.darf === true, "die Community darf eine Anfrage stellen"); + ok(Array.isArray(a.fragen) && a.fragen.length === fragen.BEWERBUNG_FRAGEN.length, + `und bekommt alle ${a.fragen?.length} Fragen mitgeliefert`); + ok(typeof a.wer_liest === "string" && a.wer_liest.length > 10, + `und erfährt vorher, wer es liest („${String(a.wer_liest).slice(0, 40)}…“)`); +} +for (const [name, k] of [["ein Modi", kModi], ["die rechte Hand", kHand], ["DogFather", kDogi]]) { + const a = alsJson(await holen("/workspace/api/bewerbung", k)); + ok(a.darf === false, `${name} bekommt kein Formular – wer dabei ist, bewirbt sich nicht`); +} + +/* ======================================================================= + 3. ABSCHICKEN + ======================================================================= */ +melde(""); +melde("=== 3. Abschicken ==="); +{ + /* Eine Pflichtantwort fehlt. Der Server sagt WELCHE -- sonst sucht + man in fuenfzehn Feldern. */ + const unvoll = bogen(); + delete unvoll[fragen.PFLICHT[2]]; + const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: unvoll }); + const a = alsJson(r); + ok(r.code === 400, `eine fehlende Pflichtantwort wird abgewiesen (${r.code})`); + ok(Array.isArray(a.fehlt) && a.fehlt.includes(fragen.PFLICHT[2]), + `und der Server sagt, welche (${JSON.stringify(a.fehlt)})`); + const zahl = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) + .prepare("SELECT COUNT(*) n FROM bewerbungen").get().n; + ok(zahl === 0, "und es liegt nichts halb Fertiges in der Datenbank"); +} +{ + const r = await schicken("/workspace/api/bewerbung", kKessi, + { antworten: bogen({ name: "Kessi", warum: "Ich bin fast jeden Abend da." }) }); + ok(r.code === 201, `ein vollständiger Bogen geht durch (${r.code})`); +} +{ + const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: bogen() }); + ok(r.code === 409, `eine zweite Anfrage geht nicht, solange die erste offen ist (${r.code})`); +} +{ + /* FREMDE HERKUNFT. Ein Formular auf einer fremden Seite koennte den + Weg im Browser eines Angemeldeten ausloesen. */ + const r = await roh("/workspace/api/bewerbung", CREW, + { Cookie: kBenno.keks, Origin: "https://boese.example" }, { antworten: bogen() }); + ok(r.code === 403, `aus fremder Herkunft geht nichts (${r.code})`); + const gut = await schicken("/workspace/api/bewerbung", kBenno, + { antworten: bogen({ name: "Benno" }) }); + ok(gut.code === 201, + `– und die Gegenprobe: aus dem eigenen Haus geht es sehr wohl (${gut.code})`); +} + +/* ======================================================================= + 4. VERTRAULICH + ======================================================================= */ +melde(""); +melde("=== 4. Wer sie lesen darf ==="); +for (const [name, k] of [["ein Modi", kModi], ["die Community selbst", kKessi]]) { + const r = await holen("/workspace/api/bewerbungen", k); + ok(r.code === 404, + `${name} bekommt den Eingang nicht – und zwar 404, nicht „kein Zugriff“ (${r.code})`); +} +for (const [name, k] of [["DogFather", kDogi], ["die rechte Hand", kHand]]) { + const r = await holen("/workspace/api/bewerbungen", k); + const a = alsJson(r); + ok(r.code === 200 && a.bewerbungen?.length === 2, + `${name} sieht beide Anfragen (${r.code}, ${a.bewerbungen?.length})`); +} +{ + const a = alsJson(await holen("/workspace/api/bewerbungen", kDogi)); + const eine = a.bewerbungen.find((b) => b.nenn_name === "Kessi"); + ok(eine?.antworten?.warum === "Ich bin fast jeden Abend da.", + "die Antworten kommen vollständig mit"); + ok(typeof eine?.tage === "number", + `und dabei steht, wie lange sie schon liegt (${eine?.tage} Tage)`); +} +{ + /* IM PROTOKOLL STEHT KEIN INHALT. Das Protokoll lesen mehr Leute als + die Anfrage selbst. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) + .prepare("SELECT aktion, detail FROM protokoll WHERE aktion LIKE 'bewerbung%'").all(); + ok(p.length >= 2, `das Protokoll hält die Anfragen fest (${p.length} Zeilen)`); + const verraten = p.filter((z) => /jeden Abend|Antwort auf/.test(String(z.detail || ""))); + ok(verraten.length === 0, + verraten.length ? `Inhalt im Protokoll: ${JSON.stringify(verraten[0])}` + : "aber keine Zeile Inhalt – nur die Nummer"); +} + +/* ======================================================================= + 5. ENTSCHEIDEN — und der Satz, der bei der Person ankommt + ======================================================================= */ +melde(""); +melde("=== 5. Entscheiden ==="); +const idKessiAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi)) + .bewerbungen.find((b) => b.nenn_name === "Kessi").id; +{ + const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, + { weg: "gespraech", grund: "" }, "PUT"); + ok(r.code === 400, `„Reden“ ohne einen Satz geht nicht (${r.code})`); + /* WARUM DAS EINE ECHTE REGEL IST und keine Gaengelei: Es gibt keinen + zweiten Weg, dieser Person etwas zu sagen. Ein Zugang aus der + Community steht nicht in den Chatlisten. */ + ok(rollen.BEWERBUNG_ROLLEN.has("gast"), + "denn eine Anfrage kommt aus der Community – und die steht in keiner Chatliste"); +} +{ + const satz = "Wie viele Abende in der Woche schaffst du realistisch?"; + const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, + { weg: "gespraech", grund: satz }, "PUT"); + ok(r.code === 200, `mit Satz geht es (${r.code})`); + const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi)); + ok(eigen.meine?.zustand === "gespraech" && eigen.meine?.grund === satz, + "und der Satz steht auf ihrer eigenen Seite – das ist das „sofort kommunizieren“"); + ok(eigen.offen === true, + "die Anfrage bleibt dabei offen – „Reden“ ist keine Entscheidung"); +} +{ + /* DIE RECHTE HAND ENTSCHEIDET MIT -- Filipes Antwort auf die + Rueckfrage vom 17.09.: „Du und die rechte Hand." */ + const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kHand, + { weg: "warteschlange", grund: "" }, "PUT"); + ok(r.code === 200, `die rechte Hand darf ebenfalls entscheiden (${r.code})`); + const r2 = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kModi, + { weg: "angenommen", grund: "" }, "PUT"); + ok(r2.code === 404, `ein Modi nicht – und erfährt auch nicht, dass es sie gibt (${r2.code})`); +} +{ + const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, + { weg: "voellig-erfunden", grund: "x" }, "PUT"); + ok(r.code === 400, `ein erfundener Weg wird abgewiesen (${r.code})`); +} + +/* ======================================================================= + 6. DIE BRÜCKE ZU DEN TALENTEN + ======================================================================= */ +melde(""); +melde("=== 6. Aus einer Anfrage wird ein Talent ==="); +{ + const vorher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) + .prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n; + ok(vorher === 0, "vorher steht niemand bei den Talenten"); + + const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, + { weg: "angenommen", grund: "Willkommen." }, "PUT"); + const a = alsJson(r); + ok(r.code === 200 && a.eintrag_id > 0, `„Annehmen“ legt ein Talent an (${r.code})`); + + const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const e = db2.prepare( + "SELECT titel, bereich FROM eintraege WHERE id = ?").get(a.eintrag_id); + ok(e?.bereich === "talente" && e?.titel === "Kessi", + `und zwar unter dem Namen, den sie selbst genannt hat („${e?.titel}“)`); + const st = db2.prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(a.eintrag_id); + /* AUF „ANGESPROCHEN" -- die ersten beiden Stufen fragen, ob ueberhaupt + Interesse besteht. Wer sich selbst meldet, hat das beantwortet. */ + ok(st?.stufe === "angesprochen", + `auf der Stufe, die zur Selbstmeldung passt („${st?.stufe}“)`); + + /* GENAU EINMAL. Doppelklick, Zurueck-Taste, zweites Geraet -- eine + zweite Karte im Trichter faellt erst auf, wenn man dieselbe Person + zweimal anspricht. */ + const nochmal = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi, + { weg: "angenommen", grund: "Willkommen." }, "PUT"); + const b = alsJson(nochmal); + const nachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) + .prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n; + ok(nachher === 1, `zweimal annehmen ergibt kein zweites Talent (${nachher})`); + ok(b.eintrag_id === a.eintrag_id, "sondern denselben Eintrag"); +} +{ + const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi)); + ok(eigen.meine?.zustand === "angenommen" && eigen.offen === false, + "und sie sieht auf ihrer Seite, dass sie dabei ist"); +} + +/* ======================================================================= + 7. ABLEHNEN — mit Grund, und danach eine Frist + ======================================================================= */ +melde(""); +melde("=== 7. Ablehnen ==="); +const idBennoAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi)) + .bewerbungen.find((b) => b.nenn_name === "Benno").id; +{ + const r = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi, + { weg: "abgelehnt", grund: "nein" }, "PUT"); + ok(r.code === 400, `eine Ablehnung ohne Satz geht nicht (${r.code})`); + const r2 = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi, + { weg: "abgelehnt", grund: "Gerade zu wenig Zeit – melde dich im Frühjahr nochmal." }, "PUT"); + ok(r2.code === 200, `mit Satz geht es (${r2.code})`); + const eigen = alsJson(await holen("/workspace/api/bewerbung", kBenno)); + ok(String(eigen.meine?.grund || "").includes("Frühjahr"), + "und der Satz steht auf seiner Seite – nicht nur in der Akte"); + ok(eigen.sperre?.tage > 0, + `danach läuft eine Frist (${eigen.sperre?.tage} von ${eigen.sperre?.gesamt} Tagen)`); + const neu = await schicken("/workspace/api/bewerbung", kBenno, { antworten: bogen({ name: "Benno" }) }); + ok(neu.code === 429, `und eine neue Anfrage geht in dieser Frist nicht (${neu.code})`); + ok(/Tagen/.test(alsJson(neu).fehler || ""), + `die Absage sagt, WANN es wieder geht („${String(alsJson(neu).fehler).slice(0, 44)}…“)`); +} +{ + /* DIE GEGENPROBE ZUR FRIST: Wer nie abgelehnt wurde, kommt durch. + Sonst hiesse „429" nur, dass gerade niemand etwas schicken kann. */ + const r = await schicken("/workspace/api/bewerbung", kCora, { antworten: bogen({ name: "Cora" }) }); + ok(r.code === 201, `wer nie abgelehnt wurde, kann schicken (${r.code})`); +} + +/* ======================================================================= + 8. DIE WEGE DORTHIN + ======================================================================= */ +melde(""); +melde("=== 8. Wie man hinkommt ==="); +{ + /* Der Knopf auf dem Brett „Mitmachen" -- vom Server, nicht aus einer + Datei, die jeder herunterladen kann. */ + const a = alsJson(await holen("/workspace/api/bereich/mitmachen", kCora)); + ok(a.weiter?.ziel === "bewerben.html", + "auf „Mitmachen“ steht für die Community der Weg zur Anfrage"); + const b = alsJson(await holen("/workspace/api/bereich/mitmachen", kModi)); + ok(!b.weiter, + "ein Modi sieht ihn nicht – ein Knopf, den der Server ablehnt, wäre schlimmer als keiner"); + const c = alsJson(await holen("/workspace/api/bereich/regeln", kCora)); + ok(!c.weiter, "und auf den anderen Brettern steht er auch nicht"); +} +{ + const a = alsJson(await holen("/workspace/api/bewerbungen-zahl", kDogi)); + ok(a.offen === 1 && a.neu === 1, + `die Talente-Seite bekommt nur die Zahl (offen ${a.offen}, neu ${a.neu})`); + const r = await holen("/workspace/api/bewerbungen-zahl", kModi); + ok(r.code === 404, `und auch die nur die beiden (${r.code})`); +} +{ + /* DIE SEITEN SELBST. Sie haben keine Kachel -- also muss die + Adressregel sie kennen, sonst landet man auf der Startseite. */ + for (const [name, k, seite, erwartet] of [ + ["die Community", kKessi, "bewerben.html", 200], + ["ein Modi", kModi, "bewerben.html", 302], + ["DogFather", kDogi, "bewerbungen.html", 200], + ["die rechte Hand", kHand, "bewerbungen.html", 200], + ["ein Modi", kModi, "bewerbungen.html", 302], + ["die Community", kKessi, "bewerbungen.html", 302], + ]) { + const r = await holen("/workspace/" + seite, k); + ok(r.code === erwartet, `${name} → ${seite}: ${r.code}`); + } + /* AUCH AUF DER AGENTURADRESSE -- DogFather arbeitet auf beiden. */ + const r = await roh("/workspace/bewerbungen.html", HAUS, { Cookie: kDogi.keks }); + ok(r.code === 200 || r.code === 302, + `auf workspace. antwortet die Seite ebenfalls (${r.code})`); +} + +/* ======================================================================= + 9. DIE OBERFLÄCHE + ======================================================================= */ +melde(""); +melde("=== 9. Im echten Browser ==="); +{ + let chromium; + try { + ({ chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs")); + } catch { chromium = null; } + + if (!chromium) { + unklar("kein Playwright gefunden – die Oberfläche wurde nicht angesehen"); + } else { + const browser = await chromium.launch(); + try { + const sicht = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const kekse = (k) => k.keks.split("; ").map((c) => { + const [name, ...rest] = c.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + }); + + /* ---- Der Fragebogen ---- */ + await sicht.addCookies(kekse(kCora)); + const s1 = await sicht.newPage(); + const konsole = []; + s1.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + await s1.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`, + { waitUntil: "domcontentloaded" }); + await s1.waitForTimeout(900); + const f = await s1.evaluate(() => ({ + stand: document.getElementById('stand')?.textContent?.trim() || '', + bogenDa: !document.getElementById('bogen')?.hidden, + felder: document.querySelectorAll('.bw-feld__eingabe').length, + pflicht: document.querySelectorAll('.bw-feld__pflicht').length, + gruppen: document.querySelectorAll('.bw-gruppe').length, + })); + /* Cora hat gerade geschickt -- sie sieht den Stand, nicht das + Formular. Das ist die richtige Antwort und zugleich der Beweis, + dass die Seite den Zustand liest. */ + ok(f.bogenDa === false && /liegt vor/.test(f.stand), + `wer schon geschickt hat, sieht den Stand statt des Formulars („${f.stand.slice(0, 32)}…“)`); + ok(konsole.length === 0, + konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "ohne Fehler in der Konsole"); + await s1.close(); + await sicht.close(); + + /* Ein unverbrauchter Zugang, um das Formular selbst zu sehen. */ + const idDina = (() => { + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync("CODE-GAST-0004", salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run("Dina", "gast", hash, salt, 32768, + createHmac("sha256", schluessel).update("CODE-GAST-0004").digest("hex"), jetzt); + const id = dd.prepare("SELECT last_insert_rowid() AS id").get().id; + dd.close(); + return id; + })(); + const kDina = await anmelden("gast", "CODE-GAST-0004", CREW); + const sicht2 = await browser.newContext({ viewport: { width: 390, height: 844 } }); + await sicht2.addCookies(kekse(kDina)); + const s2 = await sicht2.newPage(); + await s2.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`, + { waitUntil: "domcontentloaded" }); + await s2.waitForTimeout(900); + const g = await s2.evaluate(() => ({ + felder: document.querySelectorAll('.bw-feld__eingabe').length, + pflicht: document.querySelectorAll('.bw-feld__pflicht').length, + gruppen: document.querySelectorAll('.bw-gruppe').length, + werLiest: document.getElementById('wer-liest')?.textContent || '', + })); + ok(g.felder === fragen.BEWERBUNG_FRAGEN.length, + `der Bogen zeichnet alle ${g.felder} Fragen`); + ok(g.pflicht === fragen.PFLICHT.length, + `und markiert genau die ${g.pflicht} Pflichtfragen`); + ok(g.gruppen === fragen.BEWERBUNG_GRUPPEN.length, + `in ${g.gruppen} Abschnitten`); + ok(/DogFather/.test(g.werLiest), + "über dem Absenden steht, wer es liest"); + + /* ABSCHICKEN OHNE PFLICHTANTWORT -- die Seite markiert, sie + schickt nicht. */ + await s2.click('#abschicken'); + await s2.waitForTimeout(400); + const h = await s2.evaluate(() => ({ + markiert: document.querySelectorAll('.bw-feld--fehlt').length, + meldung: document.getElementById('fehler')?.textContent || '', + })); + ok(h.markiert === fragen.PFLICHT.length, + `ein leerer Bogen markiert die ${h.markiert} fehlenden Felder`); + ok(/fehlen|fehlt/.test(h.meldung), + `und sagt es in Worten („${h.meldung.slice(0, 40)}“)`); + const zahlNachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }) + .prepare("SELECT COUNT(*) n FROM bewerbungen WHERE person_id = ?").get(idDina).n; + ok(zahlNachher === 0, "und es wurde nichts geschickt"); + if (process.env.SCHIRM) { + await s2.screenshot({ path: "server/pruef-bewerbung-bogen.png", fullPage: true }); + melde(" Bild: server/pruef-bewerbung-bogen.png"); + } + await s2.close(); + await sicht2.close(); + + /* ---- Der Eingang ---- */ + const sicht3 = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await sicht3.addCookies(kekse(kDogi)); + const s3 = await sicht3.newPage(); + const konsole3 = []; + s3.on("console", (m) => { if (m.type() === "error") konsole3.push(m.text()); }); + await s3.goto(`http://127.0.0.1:${PORT}/workspace/bewerbungen.html`, + { waitUntil: "domcontentloaded" }); + await s3.waitForTimeout(900); + const e = await s3.evaluate(() => ({ + karten: document.querySelectorAll('.bw-karte').length, + wege: document.querySelectorAll('.bw-karte .bw-weg').length, + zahlen: document.querySelectorAll('#zahlen .bilanz-zahl').length, + antworten: document.querySelectorAll('.bw-antwort').length, + })); + ok(e.karten === 3, `der Eingang zeigt alle drei Anfragen (${e.karten})`); + ok(e.wege >= 12, `mit vier Wegen je Karte (${e.wege} Knöpfe)`); + ok(e.zahlen === 3, `und drei Zahlen oben (${e.zahlen})`); + ok(e.antworten > 10, `die Antworten stehen darin (${e.antworten} Zeilen)`); + ok(konsole3.length === 0, + konsole3.length ? `Fehler in der Konsole: ${konsole3[0]}` : "ohne Fehler in der Konsole"); + + if (process.env.SCHIRM) { + await s3.screenshot({ path: "server/pruef-bewerbung.png", fullPage: true }); + melde(" Bild: server/pruef-bewerbung.png"); + } + await s3.close(); + await sicht3.close(); + } finally { + await browser.close(); + } + } +} + +melde(""); +melde(`${geprueft} geprüft, ${fehler} Fehler` + + (offen ? `, ${offen} konnte(n) nicht nachgesehen werden` : "")); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index 42abb072..ce2cbbec 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -208,6 +208,20 @@ const zeigerQuellen = [ { name: "server/workspace.js", pfad: join(SERVER, "workspace.js") }, /* Die Zugangswaende -- crew-index.html wird dort ausgeliefert. */ { name: "server/crew-adresse.js", pfad: join(SERVER, "crew-adresse.js") }, + /* EIN TEILEN-ZIEL IST EIN VERWEIS (17.09.2026). + + teilen.html stand hier als "von nirgendwo verlinkt" -- und das war + falsch: Auf sie zeigt der Eintrag `share_target.action` im + Manifest. Genau dafuer gibt es die Seite; einen Verweis im HTML + hat sie absichtlich nicht, weil man sie nicht aufsucht, sondern + von TikTok aus dort landet. + + Die Manifeste kommen deshalb als Quelle dazu -- nicht die eine + Seite als Ausnahme. Eine Ausnahmeliste haette beim naechsten + Teilen-Ziel wieder gefehlt. */ + ...["app.webmanifest", "crew.webmanifest"] + .map((n) => ({ name: n, pfad: join(WS, n) })) + .filter((q) => existsSync(q.pfad)), ]; const verlinkt = new Set(["index.html", "start.html"]); for (const { pfad: quelle } of zeigerQuellen) { diff --git a/server/rechte.js b/server/rechte.js index 45fc53e7..8d8a2bd4 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -281,6 +281,20 @@ export const SEITEN = { Stufen und die Bedingungen des Ausschlusses. */ "/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"], + /* DIE ANFRAGE FUER MODI (17.09.2026). + + NUR "gast" -- und das ist keine Schlamperei, sondern die Aussage. + Wer schon im Team ist, hat hier nichts zu beantworten; und ein + Formular, das auch Modis sehen, waere die Einladung, es als + Beurteilungsbogen zu benutzen. + + Der Server sagt dasselbe noch einmal (SCHICKEN_ROLLEN). Zwei + Schloesser an derselben Tuer, weil die Tafel umstellbar ist: + Wer sie versehentlich oeffnet, bekommt trotzdem keine Anfrage + von einem Modi in den Eingang. */ + "/workspace/bewerben.html": ["gast"], + + /* DIE ENTWICKLUNGSKARTE (11.09.2026). DogFather und die rechte Hand fuehren sie ueber das Team. Ein Modi @@ -315,6 +329,16 @@ export const SEITEN = { hat ein Recht darauf, dass der Kreis klein bleibt. */ "/workspace/talente.html": ["admin", "hand"], + /* DER EINGANG FUER DIE ANFRAGEN -- dieselben zwei wie bei den + Talenten, und aus demselben Grund: Hier stehen Saetze, die + jemand ueber sich selbst geschrieben hat, im Vertrauen darauf, + dass zwei Menschen sie lesen. + + Filipes Entscheidung vom 17.09.2026 auf die Rueckfrage, wer + entscheiden darf: "Du und die rechte Hand." */ + "/workspace/bewerbungen.html": ["admin", "hand"], + + /* MELDUNGEN & MASSNAHMEN. Nur wer moderiert -- ausdruecklich OHNE "gast": Dort stehen die Namen derer, die gemeldet haben. Eine Meldung, die der Gemeldete lesen kann, ist keine Meldung mehr, diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index d2205e7c..cfd8e51d 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -405,6 +405,17 @@ aufgabenRouter.get("/workspace/api/personen", (req, res) => { const mitBild = (z) => ({ id: z.id, name: z.name, rolle: z.rolle, gruppe: ROLLEN_GRUPPE[z.rolle] || "Weitere", + /* WER AUFGABEN AUS DEM KATALOG BEKOMMEN KANN (17.09.2026). + + Der Browser hatte das selbst entschieden -- + `p.rolle === 'modi' || p.rolle === 'hand'`, mitten in + aufgaben.js. Diese Datei kann jeder ohne Anmeldung aus dem + offenen Netz lesen; damit stand dort schwarz auf weiss, wie + der verborgene Zugang heisst. + + Jetzt entscheidet es der Server und schickt ein Ja/Nein. Aus + einem "true" laesst sich nichts ablesen. */ + fuer_katalog: TEAM_DOGI_ROLLEN.has(z.rolle), bild: z.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null, }); const ids = sichtbarePersonenIds(req.person); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 933d83ad..31d6c67a 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -46,6 +46,10 @@ import { darfSchreiben, freigabeBedingung, entfernenVorbereiten, TREFF_TEAM_ROLLEN, TREFF_FREIGABE_BRETTER, mitRollenname, } from "./workspace-treff.js"; +/* Wer eine Anfrage stellen darf -- dieselbe Menge, die der + Bewerbungsweg selbst prueft. Eine zweite hier waere die, die beim + naechsten Nachschaerfen auseinanderlaeuft. */ +import { BEWERBUNG_ROLLEN } from "./workspace-bewerbung-rollen.js"; export const bereicheRouter = express.Router(); @@ -1324,8 +1328,36 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { : (req.sicht || req.person)?.haus === "crew" ? "Team Dogi" : "Agentur"; + /* DER NAECHSTE SCHRITT -- vom Server, nicht vom Browser (17.09.2026). + + Auf dem Brett "Mitmachen" steht, WIE man dazugehoeren kann. Was + fehlte, war die Stelle, an der man es dann auch tut: Filipe, + mit dem Bildschirmfoto dieses Bretts, "ich will dass die leute + sich da anonym bei mir melden koennen". + + WARUM DER SERVER DEN KNOPF ENTSCHEIDET und nicht bereich.js: + Weil die Frage "darf diese Person?" an der Rolle haengt, und + Rollennamen stehen in keiner Datei, die jeder herunterlaedt -- + bereich.js kann ohne Anmeldung aus dem offenen Netz gelesen + werden (gemessen am 17.09.). Der Browser bekommt hier einen + fertigen Satz oder gar nichts. + + UND EIN KNOPF, DEN DER SERVER ABLEHNT, WAERE SCHLIMMER ALS + KEINER. Wer im Team ist, sieht ihn deshalb nicht. */ + const weiter = (bereich === "mitmachen" + && BEWERBUNG_ROLLEN.has((req.sicht || req.person)?.rolle)) + ? { + titel: "Du willst Modi werden?", + text: "Ein paar Fragen, ehrlich beantwortet. Es liest niemand " + + "ausser DogFather und der rechten Hand – kein anderes " + + "Mitglied, kein Modi.", + knopf: "Anfrage stellen", + ziel: "bewerben.html", + } + : null; + res.json({ - bereich, einstellung, treff: treffBrett, gehoert, + bereich, einstellung, treff: treffBrett, gehoert, weiter, eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege, }); } catch (fehler) { diff --git a/server/workspace-bewerbung-fragen.js b/server/workspace-bewerbung-fragen.js new file mode 100644 index 00000000..82bb2b49 --- /dev/null +++ b/server/workspace-bewerbung-fragen.js @@ -0,0 +1,300 @@ +/* ===================================================================== + DER FRAGEBOGEN FÜR EINE MODI-ANFRAGE (17.09.2026) + + Filipe: „ich will dass die leute sich da anonym bei mir melden + können und eine anfrage machen können für modi. ich will dass es + aber profissionel gemacht ist bitte wie ein fragebogen wieso die + person modi werden will warum sie sollte, positiv negative + sachen … perfektioniere es auch mit sachen an die ich nicht denke." + + --------------------------------------------------------------------- + WAS „ANONYM" HIER HEISST (Filipes Entscheidung, 17.09.2026) + + Vertraulich, aber nicht namenlos. Wer eine Anfrage schickt, ist + angemeldet — sonst wäre das Formular ein Briefkasten für Spam und + Scherzbewerbungen, und es gäbe keinen Weg zurückzuschreiben. + + Aber: Die Anfrage erscheint NIRGENDS öffentlich. Kein anderes + Community-Mitglied sieht sie, kein Modi sieht sie. Nur DogFather + und die rechte Hand. Und wie jemand genannt werden will, entscheidet + er selbst — der Klarname wird nie verlangt. + + --------------------------------------------------------------------- + DIE VIER FRAGEN, AN DIE MAN NICHT DENKT + + Filipe hat nach „positiv negative sachen" gefragt und ausdrücklich + um das gebeten, was ihm selbst nicht einfällt. Recherchiert + (Jotform, Typeform, Paperform, BlockSurvey — Vorlagen für Twitch- + und Discord-Moderatoren) und um vier Fragen ergänzt, die in fast + jeder guten Vorlage stehen und in fast keiner selbstgebauten: + + 1. ALTER. Steht nicht aus Neugier da. Für manches braucht es + Volljährigkeit (Meldungen an Plattformen, heikle Vorfälle), und + es steht bereits als Frage im Brett „Mitmachen". Wer es erst + nach der Zusage erfährt, muss sie zurücknehmen. + + 2. SCHON MAL GESPERRT? Die unbequemste Frage und die + aufschlussreichste. Nicht, weil eine alte Sperre + disqualifiziert — sondern weil die Antwort zeigt, wie jemand + mit eigenen Fehlern umgeht. Wer sie verschweigt und es kommt + später heraus, hat ein größeres Problem als die Sperre. + + 3. WAS, WENN EIN FREUND DIE REGELN BRICHT? Der häufigste echte + Konflikt im Moderationsalltag, und der, an dem die meisten + scheitern. In keiner Bewerbung, die jemand selbst schreibt, + kommt er vor. + + 4. WAS KANNST DU NICHT SO GUT? Filipes „negative sachen" — aber + als Frage an die Person selbst, nicht als Urteil über sie. Wer + darauf „nichts" antwortet, hat die Frage nicht verstanden, und + auch das ist eine Antwort. + + --------------------------------------------------------------------- + WIE DIE FRAGEN GESCHRIEBEN SIND + + Für Zuschauer, die von TikTok kommen — überwiegend jung, auf dem + Handy. Also: kurze Fragen, „du", keine Behördensprache, und zu + jeder Frage ein Satz, WARUM sie gestellt wird. Ein Fragebogen, der + nicht sagt, wozu er etwas wissen will, fühlt sich wie ein Verhör an. + + `pflicht: false` bei allem, was niemanden ausschließen darf. Eine + Pflichtfrage zu viel, und jemand bricht ab, den man gebraucht hätte. + ===================================================================== */ + +/** Wie lang eine Antwort höchstens sein darf. + * + * Nicht als Schikane, sondern weil ein Feld ohne Grenze irgendwann + * eine Datenbank mit einem Roman darin ist. 1200 Zeichen sind rund + * zwei Bildschirme auf dem Handy -- mehr schreibt niemand freiwillig, + * und wer mehr zu sagen hat, sagt es im Gespräch danach. */ +export const ANTWORT_MAX = 1200; +export const KURZ_MAX = 120; + +export const BEWERBUNG_FRAGEN = [ + /* ---- Wer bist du? ------------------------------------------------- */ + { + schluessel: "name", + gruppe: "wer", + titel: "Wie sollen wir dich nennen?", + hinweis: "Dein Name im Chat reicht. Deinen echten Namen brauchen wir nicht.", + art: "kurz", + pflicht: true, + }, + { + schluessel: "alter", + gruppe: "wer", + titel: "Wie alt bist du?", + /* DIE BEGRÜNDUNG STEHT DABEI. Eine Altersfrage ohne Grund liest + sich wie eine Hürde; mit Grund wie eine Notwendigkeit. */ + hinweis: "Nicht aus Neugier: Für manches braucht es 18, für das meiste nicht. " + + "Sag es einfach dazu, dann sagen wir dir, was geht.", + art: "kurz", + pflicht: true, + }, + { + schluessel: "dabei_seit", + gruppe: "wer", + titel: "Seit wann schaust du DogFather – und wo?", + hinweis: "Stream, DogFather Clips, HasiDog, Discord. Ungefähr reicht.", + art: "kurz", + pflicht: false, + }, + + /* ---- Warum? ------------------------------------------------------- */ + { + schluessel: "warum", + gruppe: "warum", + titel: "Warum möchtest du moderieren?", + hinweis: "Ehrlich ist besser als beeindruckend. „Ich bin sowieso immer da und " + + "es nervt mich, wenn niemand eingreift“ ist eine gute Antwort.", + art: "lang", + pflicht: true, + }, + { + schluessel: "warum_du", + gruppe: "warum", + titel: "Warum gerade du?", + hinweis: "Was bringst du mit, das nicht jeder mitbringt? Das muss nichts " + + "Großes sein – Geduld zählt mehr als Erfahrung.", + art: "lang", + pflicht: true, + }, + { + schluessel: "staerke", + gruppe: "warum", + titel: "Was kannst du besonders gut?", + hinweis: "Ruhig bleiben, schreiben, schlichten, Technik, Clips schneiden, " + + "Leute willkommen heißen – irgendetwas ist da.", + art: "lang", + pflicht: false, + }, + { + schluessel: "schwaeche", + gruppe: "warum", + titel: "Und was kannst du nicht so gut?", + /* DIE FRAGE, DIE SICH NIEMAND SELBST STELLT. Wer „nichts“ + antwortet, hat sie nicht verstanden -- und auch das ist eine + Antwort. */ + hinweis: "Jeder hat etwas. Wer hier „nichts“ schreibt, macht es sich zu leicht – " + + "und wir merken es sowieso in der ersten Woche.", + art: "lang", + pflicht: true, + }, + + /* ---- Zeit --------------------------------------------------------- */ + { + schluessel: "zeit", + gruppe: "zeit", + titel: "Wie viel Zeit hast du realistisch in der Woche?", + hinweis: "Zwei Stunden reichen. Verlässlich schlägt viel: Eine Schicht, auf die " + + "man sich verlassen kann, ist mehr wert als fünf spontane.", + art: "kurz", + pflicht: true, + }, + { + schluessel: "wann", + gruppe: "zeit", + titel: "Wann kannst du meistens?", + hinweis: "Abends, am Wochenende, tagsüber? Danach richten sich die Schichten.", + art: "kurz", + pflicht: false, + }, + { + schluessel: "womit", + gruppe: "zeit", + titel: "Was würdest du am liebsten machen?", + hinweis: "Chat moderieren, Clips schneiden, Community betreuen, Technik – " + + "oder etwas ganz anderes.", + art: "lang", + pflicht: false, + }, + + /* ---- Erfahrung und Ehrlichkeit ------------------------------------ */ + { + schluessel: "erfahrung", + gruppe: "erfahrung", + titel: "Hast du schon mal moderiert?", + hinweis: "Wo, wie lange, wie groß? Wenn nein: auch völlig in Ordnung – " + + "fast alle im Team haben ohne Erfahrung angefangen.", + art: "lang", + pflicht: false, + }, + { + schluessel: "gesperrt", + gruppe: "erfahrung", + titel: "Wurdest du schon einmal irgendwo gesperrt oder verwarnt?", + /* DIE UNBEQUEMSTE FRAGE -- und die aufschlussreichste. Es geht + nicht um die Sperre, sondern um den Umgang damit. */ + hinweis: "Wenn ja: wo und warum? Das ist kein Ausschlussgrund – wirklich nicht. " + + "Aber wenn es später herauskommt und hier stand „nein“, ist es einer.", + art: "lang", + pflicht: true, + }, + + /* ---- Zwei Situationen --------------------------------------------- */ + { + schluessel: "fall_beleidigung", + gruppe: "situation", + titel: "Jemand beleidigt DogFather im Chat, mitten im Stream. Was machst du?", + hinweis: "Es gibt keine Musterlösung. Uns interessiert, wie du denkst.", + art: "lang", + pflicht: true, + }, + { + schluessel: "fall_freund", + gruppe: "situation", + titel: "Ein Freund von dir bricht die Regeln. Was machst du?", + /* DER HÄUFIGSTE ECHTE KONFLIKT -- und der, an dem die meisten + scheitern. In keiner selbst geschriebenen Bewerbung kommt er + vor. */ + hinweis: "Die Frage kommt im Alltag öfter, als man denkt.", + art: "lang", + pflicht: true, + }, + + /* ---- Zum Schluss --------------------------------------------------- */ + { + schluessel: "sonst", + gruppe: "schluss", + titel: "Gibt es sonst etwas, das wir wissen sollten?", + hinweis: "Alles, was oben nicht gepasst hat. Darf auch leer bleiben.", + art: "lang", + pflicht: false, + }, +]; + +/** Die Gruppen, in der Reihenfolge, in der sie im Formular stehen. */ +export const BEWERBUNG_GRUPPEN = [ + { schluessel: "wer", name: "Wer bist du?", + text: "Drei kurze Angaben. Deinen echten Namen brauchen wir nicht." }, + { schluessel: "warum", name: "Warum?", + text: "Der wichtigste Teil. Nimm dir zwei Minuten." }, + { schluessel: "zeit", name: "Zeit", + text: "Damit wir wissen, was realistisch ist – und dich nicht überfordern." }, + { schluessel: "erfahrung", name: "Erfahrung", + text: "Ohne Erfahrung anzufangen ist der Normalfall, nicht die Ausnahme." }, + { schluessel: "situation", name: "Zwei Situationen", + text: "Kein Test mit richtigen Antworten. Wir wollen sehen, wie du denkst." }, + { schluessel: "schluss", name: "Zum Schluss", text: "" }, +]; + +/** Die vier Wege, die eine Anfrage nehmen kann. + * + * Filipe: „dan soll ich annehmen ablehnen, in warteschlange stellen + * oder sofort kommunizieren können." + * + * `neu` steht nicht dabei -- das ist kein Weg, sondern der Zustand + * davor. */ +export const BEWERBUNG_WEGE = [ + { schluessel: "gespraech", name: "Reden", + text: "Erst mal schreiben. Die Anfrage bleibt offen.", + /* ZUERST, und das ist Absicht: Die häufigste richtige Antwort auf + eine Bewerbung ist eine Rückfrage, keine Entscheidung. Steht + „Annehmen“ oben, klickt man es. */ + weiter: "offen", + /* EINE NACHRICHT IST HIER PFLICHT, und das ist der ganze Sinn + dieses Weges. „DogFather möchte mit dir reden“ ohne einen Satz + dahinter ist keine Rückfrage, sondern eine Wartemarke -- die + Person weiß danach genau so wenig wie vorher und kann nichts + tun. Der Satz IST das Reden: Ein Zugang aus der Community steht + nicht in den Chatlisten (AUSSEN_ROLLEN), also gibt es keinen + zweiten Weg, ihr etwas zu sagen. */ + nachricht: "pflicht", + nachrichtSchild: "Was willst du wissen? Der Satz steht auf ihrer Seite." }, + { schluessel: "warteschlange", name: "Warteschlange", + text: "Passt – aber gerade ist kein Platz. Bleibt liegen, nicht vergessen.", + weiter: "offen", + /* Freiwillig: „warten“ ist für sich verständlich. Ein Satz dazu + macht es freundlicher, aber ihn zu verlangen hieße, dass eine + ehrliche Antwort an der Formulierung scheitert. */ + nachricht: "kann", + nachrichtSchild: "Optional: Woran es gerade liegt." }, + { schluessel: "angenommen", name: "Annehmen", + text: "Wird zum Talent und durchläuft von dort den normalen Weg.", + weiter: "talent", + nachricht: "kann", + nachrichtSchild: "Optional: ein Satz zur Begrüßung." }, + { schluessel: "abgelehnt", name: "Ablehnen", + text: "Mit einem Grund, der auch gesagt werden kann.", + weiter: "zu", + /* PFLICHT -- nicht für die Akte, für den Menschen. „Abgelehnt“ ohne + Satz ist das, was einen nicht wiederkommen lässt; und in drei + Monaten fällt der Grund niemandem mehr ein. */ + nachricht: "pflicht", + nachrichtSchild: "Warum nicht? Der Satz geht an die Person." }, +]; + +/** Wie lang eine Pflicht-Nachricht mindestens sein muss. Steht hier + * und nicht im Server: Die Oberfläche muss dieselbe Zahl kennen, sonst + * verlangt eine von beiden etwas anderes. */ +export const NACHRICHT_MIN = 10; + +export const BEWERBUNG_ZUSTAENDE = ["neu", ...BEWERBUNG_WEGE.map((w) => w.schluessel)]; + +/** Schnellzugriff. */ +export const FRAGE_NACH_SCHLUESSEL = new Map( + BEWERBUNG_FRAGEN.map((f) => [f.schluessel, f])); + +/** Die Pflichtfragen -- abgeleitet, damit Server und Oberfläche + * dieselbe Liste benutzen und nicht zwei pflegen. */ +export const PFLICHT = BEWERBUNG_FRAGEN.filter((f) => f.pflicht).map((f) => f.schluessel); diff --git a/server/workspace-bewerbung-rollen.js b/server/workspace-bewerbung-rollen.js new file mode 100644 index 00000000..030b1fc0 --- /dev/null +++ b/server/workspace-bewerbung-rollen.js @@ -0,0 +1,34 @@ +/* ===================================================================== + WER BEI EINER MODI-ANFRAGE WAS DARF (17.09.2026) + + Zwei Mengen, mehr nicht -- und die Datei importiert absichtlich + nichts. + + WARUM SIE EIGENSTÄNDIG IST: workspace-bereiche.js braucht die Frage + „darf diese Person eine Anfrage stellen?", um auf dem Brett + „Mitmachen" den Knopf zu zeigen. Würde es dafür aus + workspace-bewerbung.js importieren, entstünde ein Ring + (bewerbung -> workspace -> ... -> bereiche), und ein Ring äußert + sich in JavaScript nicht als Fehler, sondern als `undefined` an + einer Stelle, an der niemand damit rechnet. + + Dasselbe Muster wie treff-tabellen.js: Ein Blatt ohne eigene + Importe kann von überall gelesen werden. + ===================================================================== */ + +/** Wer eine Anfrage SCHICKEN darf: die Community. + * + * Das Team nicht -- wer schon dabei ist, braucht keine Anfrage. Und + * ein Modi, der sich bewirbt, wäre eine Karte im Eingang, die niemand + * einordnen kann. */ +export const BEWERBUNG_ROLLEN = new Set(["gast"]); + +/** Wer eine Anfrage LESEN und entscheiden darf. + * + * Filipes Entscheidung vom 17.09.2026 auf die Rückfrage: „Du und die + * rechte Hand." + * + * Die Modis sehen nichts -- eine Anfrage enthält Alter, Schwächen und + * alte Sperren, und das vor künftigen Kollegen auszubreiten wäre ein + * schlechter Anfang für beide. */ +export const BEWERBUNG_LESEN = new Set(["admin", "hand"]); diff --git a/server/workspace-bewerbung.js b/server/workspace-bewerbung.js new file mode 100644 index 00000000..d812fe54 --- /dev/null +++ b/server/workspace-bewerbung.js @@ -0,0 +1,485 @@ +/* ===================================================================== + DIE MODI-ANFRAGE (17.09.2026) + + Filipe: „ich will dass die leute sich da anonym bei mir melden + können und eine anfrage machen können für modi … und dan soll ich + annehmen ablehnen, in warteschlange stellen oder sofort + kommunizieren können. und wenn ich annehme dan soll diese person + automatisch zu talente kategorie weiter geleitet werden." + + --------------------------------------------------------------------- + VERTRAULICH, ABER NICHT NAMENLOS (Filipes Entscheidung) + + Wer eine Anfrage schickt, ist angemeldet -- sonst wäre das Formular + ein Briefkasten für Spam, und es gäbe keinen Weg zurückzuschreiben. + Aber die Anfrage erscheint NIRGENDS öffentlich: nicht im Brett, + nicht in einer Liste, nicht in der Suche. Nur DogFather und die + rechte Hand sehen sie. + + DAS IST KEINE EINSTELLUNG, SONDERN GEBAUT: Es gibt keinen Weg, der + sie an jemand anderen ausliefert. Wer einen hinzufügt, muss das + ausdrücklich tun -- ein Versehen kann es nicht sein. + + --------------------------------------------------------------------- + EINE ANFRAGE JE PERSON, SOLANGE SIE OFFEN IST + + Nicht aus Strenge: Zwei offene Anfragen derselben Person sind zwei + Karten im Eingang, die dasselbe meinen -- und man entscheidet + zweimal über denselben Menschen, womöglich unterschiedlich. Wer + abgelehnt wurde, darf es wieder versuchen; das ist der Sinn der + Sperrfrist statt eines Verbots. + + --------------------------------------------------------------------- + UND DIE BRÜCKE ZU DEN TALENTEN + + „Annehmen" legt einen Eintrag im Brett `talente` an und setzt ihn + auf die Stufe `angesprochen`. Nicht auf `aufgefallen`: Wer sich + selbst meldet, hat sein Interesse bereits erklärt -- die ersten + beiden Stufen fragen genau das, und sie zweimal zu stellen wäre + Beschäftigung, keine Sorgfalt. + + Ab dort gilt der ganz normale Weg mit Merkmalen, Buddy, erster + Schicht und Probe. Kein zweiter Ablauf daneben. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, protokolliere, echteIp, istDogFather, heuteLokal, + ROLLEN_NAME, +} from "./workspace.js"; +import { + BEWERBUNG_FRAGEN, BEWERBUNG_GRUPPEN, BEWERBUNG_WEGE, BEWERBUNG_ZUSTAENDE, + FRAGE_NACH_SCHLUESSEL, PFLICHT, ANTWORT_MAX, KURZ_MAX, + NACHRICHT_MIN, +} from "./workspace-bewerbung-fragen.js"; +/* DIE TALENT-TABELLEN GEHÖREN NICHT UNS — wir fordern sie nur an. + `talent_stufe` entsteht erst beim ersten Aufruf der + Entwicklungsseite. Wer hier hineinschreibt, bevor jemand sie + geöffnet hat, schreibt in eine Tabelle, die es nicht gibt — und das + passiert genau einmal: beim ersten „Annehmen" auf einem frisch + aufgesetzten Server. Also zum unangenehmsten Zeitpunkt. */ +import { tabellen as entwicklungTabellen } from "./workspace-entwicklung.js"; +import { BEWERBUNG_ROLLEN, BEWERBUNG_LESEN } + from "./workspace-bewerbung-rollen.js"; + +export const bewerbungRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); + +/* DIE BEIDEN MENGEN LIEGEN IN workspace-bewerbung-rollen.js. + + Sie standen hier -- bis das Brett „Mitmachen" dieselbe Frage + brauchte („darf diese Person eine Anfrage stellen?"), um den Knopf + zu zeigen. Ein Import von dort hierher wäre ein Ring gewesen; + deshalb ein Blatt ohne eigene Importe, wie bei treff-tabellen.js. + + Zwei Mengen an zwei Orten wären die Bauart, die heute Mittag die + Rolle „Community" unanlegbar gemacht hat. */ +const LESEN_ROLLEN = BEWERBUNG_LESEN; +const SCHICKEN_ROLLEN = BEWERBUNG_ROLLEN; + +/** Nach einer Ablehnung: so viele Tage Pause. + * + * KEIN VERBOT, SONDERN EINE PAUSE. Menschen entwickeln sich, und die + * häufigste Ablehnung lautet „noch nicht" und nicht „nie". Ein + * dauerhafter Riegel wäre in einem halben Jahr falsch, und niemand + * würde ihn zurücknehmen, weil niemand mehr daran denkt. */ +export const SPERRE_TAGE = 60; + +/* ===================================================================== + DIE TABELLEN + ===================================================================== */ +let bereit = false; +export function tabellen() { + if (bereit) return; + db().exec(` + CREATE TABLE IF NOT EXISTS bewerbungen ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + /* Wie die Person genannt werden will -- ihre eigene Angabe, nicht + der Kontoname. Sie steht hier doppelt (auch in den Antworten), + weil die Liste sie ohne Verbund zeigen muss. */ + nenn_name TEXT NOT NULL, + zustand TEXT NOT NULL DEFAULT 'neu', + erstellt TEXT NOT NULL, + /* Wer entschieden hat und wann. Ohne das ist eine Ablehnung eine + Tatsache ohne Urheber -- und die erklärt hinterher niemand. */ + entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + entschieden_am TEXT, + /* Der Grund. Bei einer Ablehnung der Satz, den man auch sagen + kann; sonst eine Notiz fuer das eigene Gedaechtnis. */ + grund TEXT, + /* Das Talent, das daraus geworden ist. */ + eintrag_id INTEGER REFERENCES eintraege(id) ON DELETE SET NULL + )`); + db().exec(` + CREATE TABLE IF NOT EXISTS bewerbung_antwort ( + bewerbung_id INTEGER NOT NULL REFERENCES bewerbungen(id) ON DELETE CASCADE, + schluessel TEXT NOT NULL, + text TEXT NOT NULL, + PRIMARY KEY (bewerbung_id, schluessel) + )`); + /* JE FRAGE EINE ZEILE und nicht ein JSON-Klumpen in einer Spalte: + Kommt eine Frage dazu, braucht es keine Schemaänderung -- und eine + entfernte Frage lässt die alten Antworten stehen, statt sie + unlesbar zu machen. */ + db().exec("CREATE INDEX IF NOT EXISTS idx_bewerbung_zustand ON bewerbungen (zustand, id DESC)"); + db().exec("CREATE INDEX IF NOT EXISTS idx_bewerbung_person ON bewerbungen (person_id, id DESC)"); + entwicklungTabellen(); + bereit = true; +} + +/* ===================================================================== + DIE RIEGEL + ===================================================================== */ +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + tabellen(); + next(); +} + +/** Nur DogFather und die rechte Hand. + * + * 404 und nicht 403: Wer „kein Zugriff" liest, weiß, dass es etwas + * gibt. Dieselbe Antwort wie überall im Haus. */ +function nurLeitung(req, res, next) { + if (!LESEN_ROLLEN.has(req.person?.rolle)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +bewerbungRouter.use("/workspace/api/bewerbung", angemeldet); +bewerbungRouter.use("/workspace/api/bewerbungen", angemeldet); + +const text = (x, max) => String(x ?? "").replace(/\r\n/g, "\n").trim().slice(0, max); + +/* ===================================================================== + 1. DER FRAGEBOGEN — und ob man ihn gerade schicken darf + ===================================================================== */ +bewerbungRouter.get("/workspace/api/bewerbung", (req, res) => { + try { + const darf = SCHICKEN_ROLLEN.has(req.person.rolle); + + /* Die letzte eigene Anfrage. Sie entscheidet, was die Seite zeigt: + das Formular, den Stand -- oder die Sperrfrist. */ + const meine = db().prepare( + `SELECT id, zustand, erstellt, entschieden_am, grund + FROM bewerbungen WHERE person_id = ? ORDER BY id DESC LIMIT 1`) + .get(req.person.id); + + let sperre = null; + if (meine?.zustand === "abgelehnt" && meine.entschieden_am) { + const tage = Math.floor((Date.now() - Date.parse(meine.entschieden_am)) / 86400000); + if (tage < SPERRE_TAGE) sperre = { tage: SPERRE_TAGE - tage, gesamt: SPERRE_TAGE }; + } + const offen = meine && ["neu", "gespraech", "warteschlange"].includes(meine.zustand); + + res.json({ + darf, + fragen: BEWERBUNG_FRAGEN, + gruppen: BEWERBUNG_GRUPPEN, + grenzen: { lang: ANTWORT_MAX, kurz: KURZ_MAX }, + meine: meine + ? { zustand: meine.zustand, erstellt: meine.erstellt, grund: meine.grund ?? null } + : null, + offen: !!offen, + sperre, + /* WAS MIT DEN ANGABEN PASSIERT, steht in der Antwort und nicht + nur im Kleingedruckten der Seite: Wer ein Formular ausfüllt, + soll vorher wissen, wer es liest. */ + wer_liest: "DogFather und die rechte Hand – sonst niemand.", + /* WOFUER MAN SICH MELDET -- als Wort in der Antwort. + + Die Seite selbst nennt die Rolle nicht: Jede Datei unter + /workspace/ ist ohne Anmeldung aus dem offenen Netz lesbar + (gemessen 17.09.2026). Filipes Entscheidung vom 11.09., dass + die Community den Namen sieht, gilt weiter -- sie wird nur so + umgesetzt wie ueberall sonst im Haus: als Daten, nicht als + Konstante im Browser. */ + rolle_name: ROLLEN_NAME.modi || null, + }); + } catch (fehler) { + console.error("[bewerbung] Fragebogen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + 2. ABSCHICKEN + ===================================================================== */ +bewerbungRouter.post("/workspace/api/bewerbung", express.json({ limit: "64kb" }), + gleicheHerkunft, (req, res) => { + try { + if (!SCHICKEN_ROLLEN.has(req.person.rolle)) { + return res.status(403).json({ fehler: "Anfragen kommen aus der Community." }); + } + + /* SCHON EINE OFFEN? Dann keine zweite. */ + const offen = db().prepare( + `SELECT id FROM bewerbungen + WHERE person_id = ? AND zustand IN ('neu','gespraech','warteschlange')`) + .get(req.person.id); + if (offen) { + return res.status(409).json({ + fehler: "Deine Anfrage liegt schon vor – sie ist noch offen.", + }); + } + + /* UND DIE SPERRFRIST NACH EINER ABLEHNUNG. */ + const letzte = db().prepare( + `SELECT zustand, entschieden_am FROM bewerbungen + WHERE person_id = ? ORDER BY id DESC LIMIT 1`).get(req.person.id); + if (letzte?.zustand === "abgelehnt" && letzte.entschieden_am) { + const tage = Math.floor((Date.now() - Date.parse(letzte.entschieden_am)) / 86400000); + if (tage < SPERRE_TAGE) { + return res.status(429).json({ + fehler: `Das geht in ${SPERRE_TAGE - tage} Tagen wieder. ` + + "Eine Absage heißt hier „noch nicht“, nicht „nie“.", + }); + } + } + + /* DIE ANTWORTEN. Nur bekannte Schlüssel -- ein erfundener wäre + eine Zeile in der Datenbank, die keine Frage beantwortet. */ + const roh = req.body?.antworten; + if (!roh || typeof roh !== "object") { + return res.status(400).json({ fehler: "Da fehlen die Antworten." }); + } + const antworten = new Map(); + for (const [k, v] of Object.entries(roh)) { + const frage = FRAGE_NACH_SCHLUESSEL.get(k); + if (!frage) continue; + const wert = text(v, frage.art === "kurz" ? KURZ_MAX : ANTWORT_MAX); + if (wert) antworten.set(k, wert); + } + + /* PFLICHTFRAGEN -- die Liste kommt aus dem Katalog, nicht von + hier. Zwei Listen liefen beim ersten Hinzufügen auseinander, + und die Oberfläche würde dann etwas verlangen, das der Server + nicht prüft (oder umgekehrt). */ + const fehlt = PFLICHT.filter((k) => !antworten.has(k)); + if (fehlt.length) { + return res.status(400).json({ + fehler: "Ein paar Antworten fehlen noch.", + fehlt, + }); + } + + const nenn = antworten.get("name"); + const { lastInsertRowid } = db().prepare( + `INSERT INTO bewerbungen (person_id, nenn_name, zustand, erstellt) + VALUES (?,?, 'neu', ?)`).run(req.person.id, nenn, jetzt()); + const id = Number(lastInsertRowid); + + const rein = db().prepare( + "INSERT INTO bewerbung_antwort (bewerbung_id, schluessel, text) VALUES (?,?,?)"); + for (const [k, v] of antworten) rein.run(id, k, v); + + /* IM PROTOKOLL STEHT KEIN INHALT. Die Anfrage ist vertraulich -- + das gilt auch für das Protokoll, das mehr Leute lesen als die + Anfrage selbst. */ + protokolliere("bewerbung_eingegangen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id}`, + }); + + res.status(201).json({ id, zustand: "neu" }); + } catch (fehler) { + console.error("[bewerbung] Abschicken:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ===================================================================== + 2b. NUR DIE ZAHL — für den Weg von der Talente-Seite hierher + + Eine eigene, winzige Auskunft und NICHT der volle Eingang: Die + Talente-Seite braucht „liegt da etwas?", nicht fünfzehn Antworten je + Anfrage. Diese Sätze in eine Seite zu laden, die sie nicht anzeigt, + wäre vertraulicher Text an einem Ort, an dem ihn niemand erwartet -- + und im Zwischenspeicher des Browsers landet er trotzdem. + ===================================================================== */ +bewerbungRouter.use("/workspace/api/bewerbungen-zahl", angemeldet); +bewerbungRouter.get("/workspace/api/bewerbungen-zahl", nurLeitung, (req, res) => { + try { + const z = db().prepare( + `SELECT + SUM(CASE WHEN zustand = 'neu' THEN 1 ELSE 0 END) AS neu, + SUM(CASE WHEN zustand IN ('neu','gespraech','warteschlange') + THEN 1 ELSE 0 END) AS offen + FROM bewerbungen`).get() || {}; + res.json({ neu: z.neu || 0, offen: z.offen || 0 }); + } catch (fehler) { + console.error("[bewerbung] Zahl:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + 3. DER EINGANG — nur DogFather und die rechte Hand + ===================================================================== */ +bewerbungRouter.get("/workspace/api/bewerbungen", nurLeitung, (req, res) => { + try { + const zeilen = db().prepare(` + SELECT b.id, b.person_id, b.nenn_name, b.zustand, b.erstellt, b.grund, + b.entschieden_am, b.eintrag_id, + p.name AS konto_name, p.rolle AS konto_rolle, + e.name AS von_name + FROM bewerbungen b + LEFT JOIN personen p ON p.id = b.person_id + LEFT JOIN personen e ON e.id = b.entschieden_von + ORDER BY + /* NEUE ZUERST, DANN WAS LIEGT. Die Reihenfolge ist die + Arbeitsanweisung dieser Seite -- wie bei den Talenten. */ + CASE b.zustand WHEN 'neu' THEN 0 WHEN 'gespraech' THEN 1 + WHEN 'warteschlange' THEN 2 ELSE 3 END, + b.id DESC`).all(); + + const ids = zeilen.map((z) => z.id); + const je = new Map(); + if (ids.length) { + const platz = ids.map(() => "?").join(", "); + for (const a of db().prepare( + `SELECT bewerbung_id, schluessel, text FROM bewerbung_antwort + WHERE bewerbung_id IN (${platz})`).all(...ids)) { + if (!je.has(a.bewerbung_id)) je.set(a.bewerbung_id, {}); + je.get(a.bewerbung_id)[a.schluessel] = a.text; + } + } + + const heute = heuteLokal(); + res.json({ + fragen: BEWERBUNG_FRAGEN, + gruppen: BEWERBUNG_GRUPPEN, + wege: BEWERBUNG_WEGE, + nachricht_min: NACHRICHT_MIN, + /* Ob DIESE Person auch entscheiden darf -- die rechte Hand sieht + mit und entscheidet mit (Filipes Entscheidung vom 17.09.). */ + darf_entscheiden: true, + ich: { rolle: req.person.rolle, dogfather: istDogFather(req.person) }, + bewerbungen: zeilen.map((z) => ({ + ...z, + antworten: je.get(z.id) || {}, + /* WIE LANGE LIEGT SIE SCHON? Eine Anfrage, die drei Wochen + unbeantwortet liegt, ist eine Absage, die niemand + ausgesprochen hat. */ + tage: Math.max(0, Math.floor( + (Date.parse(heute + "T12:00:00Z") - Date.parse(z.erstellt)) / 86400000)), + })), + zahlen: Object.fromEntries(BEWERBUNG_ZUSTAENDE.map((z) => + [z, zeilen.filter((x) => x.zustand === z).length])), + }); + } catch (fehler) { + console.error("[bewerbung] Eingang:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + 4. ENTSCHEIDEN — und bei „Annehmen" die Brücke zu den Talenten + ===================================================================== */ +bewerbungRouter.put("/workspace/api/bewerbung/:id/weg", express.json({ limit: "8kb" }), + gleicheHerkunft, nurLeitung, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const b = db().prepare( + "SELECT id, person_id, nenn_name, zustand, eintrag_id FROM bewerbungen WHERE id = ?") + .get(id); + if (!b) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const wegSchluessel = String(req.body?.weg ?? ""); + const weg = BEWERBUNG_WEGE.find((w) => w.schluessel === wegSchluessel); + if (!weg) return res.status(400).json({ fehler: "Unbekannte Entscheidung." }); + + const grund = text(req.body?.grund, 600); + /* OB EINE NACHRICHT NÖTIG IST, STEHT IM KATALOG -- nicht hier. + + Vorher stand an dieser Stelle `weg.schluessel === "abgelehnt"` + und die Zahl 10. Dieselbe Regel hätte die Oberfläche ein + zweites Mal gebraucht, und zwei Listen für dieselbe Frage sind + genau die Bauart, die heute Mittag die Rolle „Community" + unanlegbar gemacht hat: Die Prüfung las eine Liste, die für + etwas anderes gedacht war. + + Jetzt liest die Oberfläche `nachricht` und `NACHRICHT_MIN` aus + derselben Antwort, die der Server selbst prüft. */ + if (weg.nachricht === "pflicht" && grund.length < NACHRICHT_MIN) { + return res.status(400).json({ + fehler: weg.schluessel === "abgelehnt" + ? "Schreib kurz dazu, warum – der Satz geht an die Person." + : "Schreib kurz dazu, worum es geht – sonst weiß sie nichts damit anzufangen.", + }); + } + + let eintragId = b.eintrag_id ?? null; + + /* ---- DIE BRÜCKE ---------------------------------------------- */ + if (weg.weiter === "talent" && !eintragId) { + const t = jetzt(); + const antworten = new Map(db().prepare( + "SELECT schluessel, text FROM bewerbung_antwort WHERE bewerbung_id = ?") + .all(id).map((z) => [z.schluessel, z.text])); + + /* Der Eintrag heißt wie die Person genannt werden will. Der + Kontoname steht bewusst nicht im Titel: Auf dem Brett lesen + ihn später auch Leute, die die Anfrage nie gesehen haben. */ + const einsatz = antworten.get("womit") || null; + const { lastInsertRowid } = db().prepare(` + INSERT INTO eintraege + (bereich, art, titel, text, datum, status, erstellt, erstellt_von, einsatz) + VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?)`) + .run(b.nenn_name, + `Hat sich selbst gemeldet (Anfrage #${id}).` + + (antworten.get("warum") ? `\n\nWarum: ${antworten.get("warum")}` : ""), + t.slice(0, 10), t, req.person.id, einsatz); + eintragId = Number(lastInsertRowid); + + /* AUF „ANGESPROCHEN" und nicht auf „aufgefallen": Die ersten + beiden Stufen fragen, ob überhaupt Interesse besteht. Wer + sich selbst gemeldet hat, hat das beantwortet -- die Frage + ein zweites Mal zu stellen wäre Beschäftigung, keine + Sorgfalt. */ + db().prepare(` + INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id, notiz) + VALUES (?, 'angesprochen', ?, ?, ?) + ON CONFLICT(eintrag_id) DO UPDATE SET stufe = 'angesprochen', seit = excluded.seit`) + .run(eintragId, t, req.person.id, `Aus der Anfrage #${id}.`); + } + + db().prepare(` + UPDATE bewerbungen + SET zustand = ?, grund = ?, entschieden_von = ?, entschieden_am = ?, + eintrag_id = ? + WHERE id = ?`) + .run(weg.schluessel, grund || null, req.person.id, jetzt(), eintragId, id); + + protokolliere("bewerbung_entschieden", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} -> ${weg.schluessel}`, + }); + + res.json({ + ok: true, zustand: weg.schluessel, eintrag_id: eintragId, + /* Wohin man jetzt springen kann. */ + weiter: eintragId ? `/workspace/talente.html` : null, + }); + } catch (fehler) { + console.error("[bewerbung] Entscheiden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 2260301e..f178e7b6 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -318,6 +318,24 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { WHERE p.rolle = 'creator' AND p.aktiv = 1 AND f.person_id IS NULL`, [])); } + /* ---------- Anfragen für Modi ---------- + + WARUM ES DAFUER EINEN HINWEIS BRAUCHT: Die Anfragen haben + ausdruecklich KEINE Kachel -- sie stehen auf der Talente-Seite, + weil sie der Anfang desselben Trichters sind. Damit gibt es auf + der Startseite nichts, was auf sie zeigt. + + Und eine Anfrage, die drei Wochen unbeantwortet liegt, ist eine + Absage, die niemand ausgesprochen hat. Genau dafuer ist die + Glocke da. + + `dazu` prueft selbst, ob die Person diese Seite hier oeffnen + darf -- ein Hinweis auf eine gesperrte Seite waere derselbe + Fehler wie am 15.09. beim Start-Check. */ + dazu("bewerbungen_neu", "offen", + "neue Anfrage für Modi", "bewerbungen.html", + zaehle("SELECT COUNT(*) n FROM bewerbungen WHERE zustand = 'neu'", [])); + hinweise.sort((x, y) => (RANG[x.stufe] - RANG[y.stufe]) || (y.anzahl - x.anzahl)); res.json({ hinweise, stand: new Date().toISOString() }); } catch (fehler) { diff --git a/server/workspace.js b/server/workspace.js index 9e4a423f..2e250f95 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -4318,6 +4318,29 @@ const OHNE_KACHEL_UEBERALL = new Set([ aber ueber „Deine Karte“ und ueber den Verweis von befinden.html dorthin -- und beides ist seine eigene Sache. */ "/workspace/entwicklung.html", + /* DIE MODI-ANFRAGE UND IHR EINGANG (17.09.2026). + + BEIDE OHNE KACHEL, und zwar mit Grund -- nicht, weil kein Platz + war: + + bewerben.html gehoert INS Brett "Mitmachen" und nicht daneben. + Eine achte Kachel im Treff waere ein zweiter Ort fuer dieselbe + Sache; das Brett erklaert, wie man dazugehoert, und der Knopf + steht genau dort, wo die Frage entsteht. + + bewerbungen.html gehoert AUF die Talente-Seite. Die Anfragen + sind der Anfang desselben Trichters -- wer angenommen wird, + steht eine Sekunde spaeter dort als Talent. Eine eigene Kachel + daneben haette den Weg in zwei Seiten zerlegt, die man + abwechselnd aufmacht. + + (Nebenbei: Ein 38. Kachelton laesst sich gemessen nicht mehr + unterbringen. Alle 37 sind vergeben, und der beste freie Rest im + Hausrahmen liegt bei einem Abstand von 39 -- das ist Dunkelrot, + also die Farbe von Spicy Media und die Farbe fuer Alarm. Das war + ein Hinweis, kein Grund.) */ + "/workspace/bewerben.html", + "/workspace/bewerbungen.html", ]); /** Gibt es diese Seite auf der Adresse, auf der die Person gerade steht? */ diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 3fd65edd..7b75d5fb 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171416", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171558", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171416", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171558", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171416", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171558", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171416", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171558", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bewerbung.css b/workspace/assets/css/bewerbung.css new file mode 100644 index 00000000..a9caf7e7 --- /dev/null +++ b/workspace/assets/css/bewerbung.css @@ -0,0 +1,274 @@ +/* ===================================================================== + DIE ANFRAGE AUS DER COMMUNITY (17.09.2026) + + Zwei Seiten teilen sich diese Datei: der Fragebogen (bewerben.html) + und der Eingang (bewerbungen.html). Eine gemeinsame Datei und nicht + zwei -- die Anfrage soll in beiden Richtungen gleich aussehen, und + zwei Dateien wären zwei Gelegenheiten, eine davon zu vergessen. + + DIE FLAECHEN SIND DECKEND, UND DAS WAR EIN FEHLER WERT. + + Zuerst stand hier `color-mix(in srgb, var(--tinte) 94%, transparent)`. + Das mischt mit DURCHSICHTIG -- 94 % heisst also: 6 % Foto scheint + durch. Auf dem Bildschirmfoto stand der Schriftzug der Buehne mitten + im Text einer Anfrage. Gefunden hat das kein Messwert, sondern das + Hinsehen; dieselbe Stelle hat mich am 17.09. schon einmal erwischt + (Talente, leerer Zustand). + + Richtig ist `#ffffff 6%` -- derselbe Ton, aber deckend. So macht es + das Haus in .t-karte. + + ALLES GEDECKT, NICHTS BLINKT. Hier liest jemand Sätze, die ein + anderer über sich selbst geschrieben hat; und auf der anderen Seite + sitzt jemand, der gerade nach Worten sucht. Beides verträgt keine + Signalfarben. Rot kommt nur an einer Stelle vor: an einem Feld, das + wirklich fehlt -- und auch dort nur als Rand. + ===================================================================== */ + +/* ===================================================================== + 1. DER FRAGEBOGEN + ===================================================================== */ + +.bw-stand { + margin: 0 0 22px; + padding: 16px 18px; + border: 1px solid var(--rand); + border-radius: 14px; + /* EIGENE FLÄCHE, KEIN DURCHSCHEINEN. Auf der Bühne liegt ein Motiv; + ohne deckenden Grund steht der Text darauf statt darüber. Das ist + am 17.09. schon einmal passiert (Talente, leerer Zustand). */ + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.bw-stand__wort { margin: 0; font-size: 1.05rem; font-weight: 700; } +.bw-stand__grund { + margin: 10px 0 0; + padding: 10px 12px; + border-inline-start: 3px solid var(--akzent); + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, var(--akzent) 8%, transparent); + line-height: 1.55; +} +.bw-stand__sperre { margin: 0; font-size: 1.05rem; font-weight: 700; } +.bw-stand .knopf { margin-top: 14px; } + +.bw-bogen { display: block; } + +.bw-gruppe { + margin: 0 0 26px; + padding: 16px 18px; + border: 1px solid var(--rand); + border-radius: 14px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.bw-gruppe__kopf { margin-bottom: 14px; } +.bw-gruppe__titel { margin: 0; font-size: 1.02rem; font-weight: 700; } +.bw-gruppe__unter { margin: 4px 0 0; font-size: .86rem; opacity: .75; } + +.bw-feld { margin: 0 0 18px; } +.bw-feld:last-child { margin-bottom: 0; } + +.bw-feld__schild { + display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; + margin: 0 0 4px; + font-weight: 600; +} +/* „nötig" und „freiwillig" stehen als WORT da und nicht als Sternchen. + Ein Sternchen muss man erklären; wer die Erklärung überliest, füllt + aus Vorsicht alles aus und schreibt am Ende nirgends etwas + Ehrliches. */ +.bw-feld__pflicht, +.bw-feld__frei { + font-size: .74rem; font-weight: 600; + letter-spacing: .04em; text-transform: uppercase; + padding: 2px 7px; border-radius: 999px; +} +.bw-feld__pflicht { + color: var(--akzent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); +} +.bw-feld__frei { opacity: .6; background: color-mix(in srgb, var(--rand) 40%, transparent); } + +.bw-feld__hinweis { margin: 0 0 6px; font-size: .86rem; opacity: .75; line-height: 1.5; } + +.bw-feld__eingabe { + display: block; width: 100%; + padding: 10px 12px; + font: inherit; font-size: .95rem; line-height: 1.55; + color: inherit; + background: color-mix(in srgb, var(--tinte) 88%, #ffffff 12%); + border: 1px solid var(--rand); border-radius: 10px; + resize: vertical; +} +.bw-feld__eingabe:focus-visible { + outline: 2px solid var(--akzent); outline-offset: 2px; + border-color: var(--akzent); +} +/* Ein fehlendes Pflichtfeld wird MARKIERT, nicht angeschrien: Rand und + ein Wort in der Meldung oben. Rot flächig wäre auf fünfzehn Feldern + eine Wand. */ +.bw-feld--fehlt .bw-feld__eingabe { + border-color: color-mix(in srgb, var(--warn) 70%, var(--rand)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--warn) 40%, transparent); +} + +.bw-feld__zaehler { margin: 4px 0 0; font-size: .78rem; opacity: .65; text-align: end; } + +.bw-wer-liest { + margin: 0 0 14px; + font-size: .86rem; opacity: .8; + padding: 10px 12px; + border: 1px dashed var(--rand); border-radius: 10px; +} + +.bw-abschluss { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } + +/* ===================================================================== + 2. DER EINGANG + ===================================================================== */ + +.bw-zahlen { + display: flex; gap: 12px; flex-wrap: wrap; + margin: 0 0 22px; +} + +.bw-karte { + margin: 0 0 20px; + padding: 16px 18px; + border: 1px solid var(--rand); + border-radius: 14px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +/* Der Zustand als feine Kante links -- er sagt beim Scrollen, was + erledigt ist, ohne dass man das Schild lesen muss. */ +.bw-karte[data-zustand="neu"] { border-inline-start: 3px solid var(--akzent); } +.bw-karte[data-zustand="gespraech"], +.bw-karte[data-zustand="warteschlange"] { + border-inline-start: 3px solid color-mix(in srgb, var(--akzent) 45%, var(--rand)); +} +/* ENTSCHIEDENE KARTEN TRETEN ZURUECK -- ueber die SCHRIFT, nicht ueber + `opacity`. Ein durchsichtiges Element nimmt seine Flaeche mit, und + hinter dieser Seite liegt ein Foto: Aus "etwas leiser" waere wieder + "das Motiv scheint durch" geworden. Derselbe Fehler, zehn Zeilen + weiter oben. */ +.bw-karte[data-zustand="angenommen"] .bw-karte__bogen, +.bw-karte[data-zustand="abgelehnt"] .bw-karte__bogen, +.bw-karte[data-zustand="angenommen"] .bw-karte__kopf, +.bw-karte[data-zustand="abgelehnt"] .bw-karte__kopf { color: color-mix(in srgb, var(--text) 78%, transparent); } + +.bw-karte__kopf { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 12px; flex-wrap: wrap; + margin-bottom: 12px; +} +.bw-karte__wer { min-width: 0; } +.bw-karte__name { margin: 0; font-size: 1.08rem; font-weight: 700; } +.bw-karte__unter { margin: 3px 0 0; font-size: .84rem; opacity: .72; } + +.bw-marke { + font-size: .76rem; font-weight: 600; + letter-spacing: .04em; text-transform: uppercase; + padding: 3px 9px; border-radius: 999px; + border: 1px solid var(--rand); + background: color-mix(in srgb, var(--tinte) 90%, #ffffff 10%); +} + +.bw-karte__frueher { margin: 0 0 12px; } +.bw-karte__grund { + margin: 0; + padding: 10px 12px; + border-inline-start: 3px solid var(--akzent); + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, var(--akzent) 8%, transparent); + line-height: 1.55; +} + +.bw-karte__bogen { margin: 0 0 16px; } +.bw-karte__gruppe { + margin: 16px 0 8px; + font-size: .78rem; font-weight: 700; + letter-spacing: .06em; text-transform: uppercase; + opacity: .65; +} +.bw-karte__gruppe:first-child { margin-top: 0; } + +.bw-antwort { margin: 0 0 10px; } +.bw-antwort__frage { margin: 0; font-size: .84rem; opacity: .7; } +.bw-antwort__text { + margin: 2px 0 0; + line-height: 1.6; + white-space: pre-wrap; +} +/* Was NICHT beantwortet wurde, steht dabei. Eine übersprungene + freiwillige Frage ist selbst eine Auskunft. */ +.bw-karte__ohne { margin: 14px 0 0; font-size: .8rem; opacity: .55; line-height: 1.5; } + +.bw-wahl { + margin-top: 14px; padding-top: 14px; + border-top: 1px solid var(--rand); +} +.bw-wahl__schild { margin: 0 0 8px; font-size: .84rem; opacity: .75; } +.bw-wahl__knoepfe { display: flex; gap: 8px; flex-wrap: wrap; } + +.bw-weg { + font: inherit; font-size: .88rem; font-weight: 600; + color: inherit; + padding: 8px 14px; + min-height: 40px; + border: 1px solid var(--rand); border-radius: 999px; + background: color-mix(in srgb, var(--tinte) 90%, #ffffff 10%); + cursor: pointer; +} +.bw-weg:hover { border-color: color-mix(in srgb, var(--akzent) 60%, var(--rand)); } +.bw-weg[aria-pressed="true"] { + border-color: var(--akzent); + background: color-mix(in srgb, var(--akzent) 16%, transparent); +} +.bw-weg:disabled { opacity: .45; cursor: default; } +.bw-weg:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +.bw-wahl__feld { margin: 12px 0 0; } +.bw-wahl__feldschild { display: block; margin: 0 0 4px; font-size: .86rem; opacity: .8; } +.bw-wahl__eingabe { + display: block; width: 100%; + padding: 10px 12px; + font: inherit; font-size: .93rem; line-height: 1.55; + color: inherit; + background: color-mix(in srgb, var(--tinte) 88%, #ffffff 12%); + border: 1px solid var(--rand); border-radius: 10px; + resize: vertical; +} +.bw-wahl__eingabe:focus-visible { + outline: 2px solid var(--akzent); outline-offset: 2px; + border-color: var(--akzent); +} +.bw-wahl .knopf { margin-top: 12px; } + +.bw-leer { + padding: 22px 20px; + border: 1px dashed var(--rand); border-radius: 14px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.bw-leer__wort { margin: 0 0 6px; font-size: 1.02rem; font-weight: 700; } + +.bw-hinweis { + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--akzent) 45%, var(--rand)); + border-radius: 12px; + background: color-mix(in srgb, var(--akzent) 10%, transparent); +} + +/* Der Weg von der Talente-Seite hierher. */ +.bw-tor { + display: flex; align-items: center; justify-content: space-between; + gap: 14px; flex-wrap: wrap; + margin: 0 0 20px; + padding: 12px 16px; + border: 1px solid var(--rand); border-radius: 12px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.bw-tor[data-neu="ja"] { + border-color: color-mix(in srgb, var(--akzent) 50%, var(--rand)); + background: color-mix(in srgb, var(--akzent) 10%, transparent); +} +.bw-tor__wort { margin: 0; font-size: .95rem; } diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index c17fbbc7..dd7468a2 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -1497,7 +1497,13 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe KEINE SCHWELLE, KEINE WARNFARBE. Was „zu viel" ist, hängt vom Menschen ab; eine feste Grenze wäre geraten. Was NICHT geraten ist: dass etwas überfällig liegt. Nur das wird markiert. */ - const zielbar = personenListe.filter((p) => p.rolle === 'modi' || p.rolle === 'hand'); + /* WER IN FRAGE KOMMT, SAGT DER SERVER (17.09.2026). + + Hier standen zwei Rollennamen. Diese Datei ist ohne Anmeldung + aus dem offenen Netz lesbar -- damit war der verborgene Zugang + darin nachzulesen. Gefunden von der Wortleck-Pruefung, nicht vom + Auge. */ + const zielbar = personenListe.filter((p) => p.fuer_katalog); if (zielbar.length && $('feld-verant') && !$('feld-verant').hidden) { /* DIE DRITTE REIHE IST ETWAS ANDERES als die zwei darueber: Sie waehlt keinen Ausschnitt des Katalogs, sondern einen MENSCHEN. diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index f67a9f79..38c51690 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -1101,6 +1101,32 @@ ziel.append(liste); } + /* ===================================================================== + DER NAECHSTE SCHRITT (17.09.2026) + + Der Server schickt entweder einen fertigen Block oder nichts. Diese + Funktion weiss nicht, worum es geht -- und genau das ist der Punkt: + Die Frage "darf diese Person?" haengt an der Rolle, und diese Datei + kann ohne Anmeldung aus dem offenen Netz gelesen werden. + + Beim naechsten Bereich, der so einen Schritt braucht, ist hier + nichts zu tun. + ===================================================================== */ + function weiterZeigen(w) { + const kasten = $('weiter'); + if (!kasten) return; + kasten.textContent = ''; + if (!w || !w.ziel) { kasten.hidden = true; return; } + kasten.hidden = false; + kasten.dataset.neu = 'ja'; + const text = el('div'); + text.append(el('p', 'bw-tor__wort', w.titel || '')); + if (w.text) text.append(el('p', 'leise', w.text)); + const hin = el('a', 'knopf', w.knopf || 'Weiter'); + hin.href = w.ziel; + kasten.append(text, hin); + } + async function laden() { try { await uebernahmenLaden(); @@ -1109,6 +1135,7 @@ const daten = await a.json(); einstellung = daten.einstellung; treffBrett = !!daten.treff; + weiterZeigen(daten.weiter); await lageHolen(); filterBauen(daten.eintraege || []); diff --git a/workspace/assets/js/bewerben.js b/workspace/assets/js/bewerben.js new file mode 100644 index 00000000..bec85866 --- /dev/null +++ b/workspace/assets/js/bewerben.js @@ -0,0 +1,279 @@ +/* ===================================================================== + DIE ANFRAGE AUS DER COMMUNITY -- der Fragebogen (17.09.2026) + + Filipe: „ich will dass die leute sich da anonym bei mir melden können + und eine anfrage machen können ... wie ein fragebogen wieso die + person es werden will warum sie sollte, positiv negative sachen." + + (Sein Satz nennt die Rolle beim Namen -- hier steht sie nicht: Diese + Datei ist ohne Anmeldung aus dem offenen Netz lesbar.) + + DIE FRAGEN STEHEN NICHT HIER. Sie kommen vom Server, mit ihren + Gruppen, ihren Pflichtmarken und den Längengrenzen. Eine zweite Liste + im Browser wäre die, die beim nächsten Nachschärfen auseinanderläuft + -- und dann verlangt das Formular etwas, das der Server nicht prüft, + oder umgekehrt. Genau dieser Fehler hat heute Mittag die Rolle + „Community" zwei Stunden lang unanlegbar gemacht. + + DER ENTWURF BLEIBT IM BROWSER, NICHT AUF DEM SERVER. + + Fünfzehn Fragen sind nichts, was man zwischen zwei Streams tippt. Wer + aufhören und später weitermachen will, muss das können -- sonst wird + die Anfrage nie fertig, und das ist dieselbe Wirkung wie eine Absage. + Der Entwurf liegt deshalb auf dem Gerät. + + Und er liegt AUSDRÜCKLICH NICHT auf dem Server: Ein halb ausgefülltes + Formular dort wäre bereits eine Anfrage, die niemand gestellt hat -- + lesbar für DogFather, bevor die Person entschieden hat, ob sie sie + überhaupt schicken will. Beim Abschicken wird der Entwurf gelöscht. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const ENTWURF = 'dogi-bewerbung-entwurf'; + + const melde = (t) => { + const f = $('fehler'); + if (f) { f.textContent = t || ''; f.hidden = !t; } + }; + + const el = (art, klasse, text) => { + const k = document.createElement(art); + if (klasse) k.className = klasse; + if (text != null) k.textContent = text; + return k; + }; + + /* Der Entwurf. Ein kaputter Eintrag darf die Seite nicht aufhalten -- + dann lieber ohne Entwurf anfangen als gar nicht. */ + const entwurfLesen = () => { + try { return JSON.parse(localStorage.getItem(ENTWURF) || '{}') || {}; } + catch { return {}; } + }; + const entwurfSchreiben = (w) => { + try { localStorage.setItem(ENTWURF, JSON.stringify(w)); } catch { /* voll oder gesperrt */ } + }; + + const ZUSTAND_WORT = { + neu: 'Deine Anfrage liegt vor.', + gespraech: 'DogFather möchte mit dir reden.', + warteschlange: 'Du stehst auf der Warteliste.', + angenommen: 'Du bist dabei.', + abgelehnt: 'Diesmal nicht.', + }; + + (async () => { + let r; + try { + const a = await fetch('/workspace/api/bewerbung', { credentials: 'same-origin' }); + if (a.status === 401) { location.assign('/workspace/'); return; } + if (!a.ok) { melde('Der Fragebogen ist gerade nicht erreichbar.'); return; } + r = await a.json(); + } catch { + melde('Der Fragebogen ist gerade nicht erreichbar.'); + return; + } + $('ladezeile').hidden = true; + + /* DAS WORT KOMMT VOM SERVER. Bleibt es aus, steht „Anfrage + stellen" da -- eine ehrliche Ueberschrift, die nichts Falsches + behauptet. Ein Platzhalter („…") waere hier schlechter: Er sieht + kaputt aus, obwohl die Seite vollstaendig arbeitet. */ + if (r.rolle_name) { + const t = $('titel'); + if (t) t.textContent = r.rolle_name + ' werden'; + const m = $('marke-was'); + if (m) m.textContent = r.rolle_name + ' werden'; + document.title = r.rolle_name + ' werden · Der Treff'; + } + + /* --------------------------------------------------------------- + 1. WAS SCHON IST -- Stand oder Sperrfrist + --------------------------------------------------------------- */ + const stand = $('stand'); + let zeigeFormular = r.darf; + + if (r.meine) { + stand.hidden = false; + stand.append(el('p', 'bw-stand__wort', + ZUSTAND_WORT[r.meine.zustand] || 'Deine Anfrage liegt vor.')); + if (r.meine.grund) { + /* DER SATZ AUS DER ENTSCHEIDUNG GEHÖRT DER PERSON. Er wird beim + Ablehnen verlangt, und zwar genau dafür -- nicht für die + Akte. Ihn hier wegzulassen hieße, ihn umsonst verlangt zu + haben. */ + stand.append(el('p', 'bw-stand__grund', r.meine.grund)); + } + if (r.offen) { + stand.append(el('p', 'leise', + 'Solange sie offen ist, kannst du keine zweite schicken. ' + + 'Du hörst von uns – das kann ein paar Tage dauern.')); + zeigeFormular = false; + } + } + + if (r.sperre) { + stand.hidden = false; + stand.append(el('p', 'bw-stand__sperre', + 'In ' + r.sperre.tage + ' Tagen kannst du es wieder versuchen.')); + stand.append(el('p', 'leise', + 'Eine Absage heißt hier „noch nicht“, nicht „nie“. ' + + 'Bis dahin: mitreden, Wünsche einstellen, dabei sein.')); + zeigeFormular = false; + } + + if (!r.darf) { + stand.hidden = false; + stand.append(el('p', 'leise', + 'Diese Seite ist für die Community. Du bist schon im Team.')); + } + + if (!zeigeFormular) return; + + /* --------------------------------------------------------------- + 2. DER BOGEN + --------------------------------------------------------------- */ + const bogen = $('bogen'); + const ziel = $('gruppen'); + const werte = entwurfLesen(); + const felder = new Map(); + + for (const g of r.gruppen) { + const fragen = r.fragen.filter((f) => f.gruppe === g.schluessel); + if (!fragen.length) continue; + + const block = el('section', 'bw-gruppe'); + const kopf = el('div', 'bw-gruppe__kopf'); + kopf.append(el('h2', 'bw-gruppe__titel', g.name)); + if (g.unter) kopf.append(el('p', 'bw-gruppe__unter', g.unter)); + block.append(kopf); + + for (const f of fragen) { + const kurz = f.art === 'kurz'; + const grenze = kurz ? r.grenzen.kurz : r.grenzen.lang; + const id = 'f-' + f.schluessel; + + const feld = el('div', 'bw-feld'); + const schild = el('label', 'bw-feld__schild'); + schild.htmlFor = id; + schild.append(document.createTextNode(f.titel)); + /* PFLICHT WIRD ANGESCHRIEBEN, NICHT ERZWUNGEN-BIS-ES-KLEMMT. + Ein Sternchen ohne Erklärung ist eine Falle; hier steht das + Wort. Und was NICHT Pflicht ist, steht auch dran -- sonst + füllt man aus Vorsicht alles aus und schreibt bei drei + Fragen nichts Ehrliches mehr. */ + schild.append(el('span', f.pflicht ? 'bw-feld__pflicht' : 'bw-feld__frei', + f.pflicht ? 'nötig' : 'freiwillig')); + feld.append(schild); + + if (f.hinweis) feld.append(el('p', 'bw-feld__hinweis', f.hinweis)); + + const eingabe = kurz ? document.createElement('input') + : document.createElement('textarea'); + eingabe.id = id; + eingabe.className = 'bw-feld__eingabe'; + eingabe.maxLength = grenze; + if (kurz) { eingabe.type = 'text'; eingabe.autocomplete = 'off'; } + else { eingabe.rows = 4; } + eingabe.value = werte[f.schluessel] || ''; + feld.append(eingabe); + + /* Der Zähler zählt, was noch geht -- nicht, was schon steht. + „Noch 80 Zeichen" ist eine Auskunft; „180 von 1200" ist eine + Aufforderung, mehr zu schreiben. Deshalb meldet er sich auch + erst, wenn es eng wird. */ + const zaehler = el('p', 'bw-feld__zaehler'); + const nachziehen = () => { + const rest = grenze - eingabe.value.length; + zaehler.textContent = rest < 80 ? ('noch ' + rest + ' Zeichen') : ''; + zaehler.hidden = rest >= 80; + }; + nachziehen(); + eingabe.addEventListener('input', () => { + werte[f.schluessel] = eingabe.value; + entwurfSchreiben(werte); + nachziehen(); + feld.classList.remove('bw-feld--fehlt'); + }); + feld.append(zaehler); + + block.append(feld); + felder.set(f.schluessel, { feld, eingabe, frage: f }); + } + ziel.append(block); + } + + $('wer-liest').textContent = 'Wer das liest: ' + (r.wer_liest || ''); + bogen.hidden = false; + + /* --------------------------------------------------------------- + 3. ABSCHICKEN + --------------------------------------------------------------- */ + bogen.addEventListener('submit', async (e) => { + e.preventDefault(); + melde(''); + + /* Die Pflichtliste kommt aus den Fragen des Servers -- dieselbe + Quelle, die er selbst prüft. */ + const fehlt = [...felder.values()] + .filter((x) => x.frage.pflicht && !x.eingabe.value.trim()); + for (const x of felder.values()) x.feld.classList.remove('bw-feld--fehlt'); + if (fehlt.length) { + for (const x of fehlt) x.feld.classList.add('bw-feld--fehlt'); + melde(fehlt.length === 1 + ? 'Eine Antwort fehlt noch – sie ist markiert.' + : fehlt.length + ' Antworten fehlen noch – sie sind markiert.'); + fehlt[0].eingabe.focus(); + fehlt[0].feld.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + + const knopf = $('abschicken'); + knopf.disabled = true; + knopf.textContent = 'wird geschickt …'; + + const antworten = {}; + for (const [k, x] of felder) { + const v = x.eingabe.value.trim(); + if (v) antworten[k] = v; + } + + try { + const a = await fetch('/workspace/api/bewerbung', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ antworten }), + }); + const d = await a.json().catch(() => ({})); + if (!a.ok) { + melde(d.fehler || 'Das hat nicht geklappt.'); + knopf.disabled = false; + knopf.textContent = 'Anfrage abschicken'; + return; + } + /* ERST JETZT den Entwurf löschen -- nicht vorher. Wäre er beim + Absenden schon weg und der Server antwortet nicht, stünde man + vor einem leeren Formular und einer halben Stunde Arbeit im + Nichts. */ + try { localStorage.removeItem(ENTWURF); } catch { /* egal */ } + + bogen.hidden = true; + const fertig = $('stand'); + fertig.hidden = false; + fertig.textContent = ''; + fertig.append(el('p', 'bw-stand__wort', 'Angekommen.')); + fertig.append(el('p', 'leise', + 'DogFather und die rechte Hand lesen sie – sonst niemand. ' + + 'Du hörst von uns; das kann ein paar Tage dauern.')); + const zurueck = el('a', 'knopf', 'Zurück zum Treff'); + zurueck.href = 'bereich.html?b=mitmachen'; + fertig.append(zurueck); + fertig.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } catch { + melde('Das hat nicht geklappt. Dein Entwurf ist noch da.'); + knopf.disabled = false; + knopf.textContent = 'Anfrage abschicken'; + } + }); + })(); +})(); diff --git a/workspace/assets/js/bewerbungen.js b/workspace/assets/js/bewerbungen.js new file mode 100644 index 00000000..3615c4e2 --- /dev/null +++ b/workspace/assets/js/bewerbungen.js @@ -0,0 +1,264 @@ +/* ===================================================================== + DER EINGANG FÜR DIE ANFRAGEN AUS DER COMMUNITY (17.09.2026) + + Filipe: „und dan soll ich annehmen ablehnen, in warteschlange stellen + oder sofort kommunizieren können." + + VIER WEGE, EINE ANTWORT. Die Reihenfolge kommt vom Server und ist + dort begründet: „Reden" steht vorne, weil die häufigste richtige + Antwort auf eine Bewerbung eine Rückfrage ist und nicht eine + Entscheidung. Stünde „Annehmen" oben, würde es geklickt. + + „SOFORT KOMMUNIZIEREN" HEISST HIER: EIN SATZ, DEN SIE LIEST. + + Nicht ein Chat -- ein Zugang aus der Community steht ausdrücklich + nicht in den Chatlisten (AUSSEN_ROLLEN, 17.09.). Es gibt also genau + einen Weg, dieser Person etwas zu sagen: den Satz, der zur + Entscheidung gehört. Er erscheint auf ihrer eigenen Seite. Deshalb + ist er bei „Reden" und „Ablehnen" Pflicht -- welche das sind, steht + im Katalog und nicht hier. + + WAS DIESE SEITE NICHT TUT: sortieren lassen, filtern, suchen. Die + Reihenfolge ist die Arbeitsanweisung (neu zuerst, dann was liegt), + und wer sie umstellen kann, arbeitet irgendwann die bequemen zuerst + ab. Bei zwanzig Anfragen im Jahr ist Suchen ohnehin keine Frage. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + + const melde = (t) => { + const f = $('fehler'); + if (f) { f.textContent = t || ''; f.hidden = !t; } + }; + + const el = (art, klasse, text) => { + const k = document.createElement(art); + if (klasse) k.className = klasse; + if (text != null) k.textContent = text; + return k; + }; + + const ZUSTAND_WORT = { + neu: 'Neu', gespraech: 'Im Gespräch', warteschlange: 'Warteliste', + angenommen: 'Angenommen', abgelehnt: 'Abgelehnt', + }; + + /* WIE LANGE SIE SCHON LIEGT, in Worten. Eine Zahl allein sagt + nichts -- „seit 23 Tagen" sagt, dass etwas schiefläuft. */ + const liegtSeit = (tage) => { + if (tage <= 0) return 'heute gekommen'; + if (tage === 1) return 'seit gestern'; + return 'liegt seit ' + tage + ' Tagen'; + }; + + let daten = null; + + const zeichnen = () => { + /* ---------- Die Zahlen oben ---------- */ + const z = $('zahlen'); + z.textContent = ''; + z.hidden = false; + const offen = (daten.zahlen.neu || 0) + (daten.zahlen.gespraech || 0) + + (daten.zahlen.warteschlange || 0); + const paare = [ + ['neu', 'Neu', daten.zahlen.neu || 0], + ['offen', 'Offen insgesamt', offen], + ['dabei', 'Angenommen', daten.zahlen.angenommen || 0], + ]; + for (const [art, schild, wert] of paare) { + const kasten = el('div', 'bilanz-zahl'); + kasten.dataset.art = art; + kasten.append(el('strong', 'bilanz-zahl__wert', String(wert))); + kasten.append(el('span', 'bilanz-zahl__schild', schild)); + z.append(kasten); + } + + /* ---------- Die Anfragen ---------- */ + const liste = $('liste'); + liste.textContent = ''; + + if (!daten.bewerbungen.length) { + const leer = el('div', 'bw-leer'); + leer.append(el('p', 'bw-leer__wort', 'Noch hat sich niemand gemeldet.')); + leer.append(el('p', 'leise', + 'Der Weg dorthin steht im Treff unter „Mitmachen“. Wenn dort ' + + 'nichts ankommt, liegt es fast nie an den Leuten, sondern ' + + 'daran, dass niemand weiß, dass es geht – sag es im Stream.')); + liste.append(leer); + return; + } + + for (const b of daten.bewerbungen) { + const karte = el('article', 'bw-karte'); + karte.dataset.zustand = b.zustand; + + /* ---- Kopf ---- */ + const kopf = el('header', 'bw-karte__kopf'); + const links = el('div', 'bw-karte__wer'); + links.append(el('h2', 'bw-karte__name', b.nenn_name || 'ohne Namen')); + const unter = el('p', 'bw-karte__unter'); + unter.append(document.createTextNode(liegtSeit(b.tage))); + /* DER KONTONAME STEHT DABEI, aber klein und dahinter. Er ist + nötig, um die Person wiederzufinden -- gemeint ist sie aber + mit dem Namen, den sie selbst genannt hat. */ + if (b.konto_name && b.konto_name !== b.nenn_name) { + unter.append(document.createTextNode(' · Zugang: ' + b.konto_name)); + } + links.append(unter); + kopf.append(links); + kopf.append(el('span', 'bw-marke', ZUSTAND_WORT[b.zustand] || b.zustand)); + karte.append(kopf); + + /* ---- Was schon entschieden wurde ---- */ + if (b.zustand !== 'neu' && (b.grund || b.von_name)) { + const alt = el('div', 'bw-karte__frueher'); + if (b.grund) alt.append(el('p', 'bw-karte__grund', b.grund)); + if (b.von_name) { + alt.append(el('p', 'leise', 'entschieden von ' + b.von_name)); + } + karte.append(alt); + } + + /* ---- Die Antworten, nach Gruppen ---- */ + const bogen = el('div', 'bw-karte__bogen'); + for (const g of daten.gruppen) { + const fragen = daten.fragen.filter( + (f) => f.gruppe === g.schluessel && b.antworten[f.schluessel]); + if (!fragen.length) continue; + bogen.append(el('h3', 'bw-karte__gruppe', g.name)); + for (const f of fragen) { + const paar = el('div', 'bw-antwort'); + paar.append(el('p', 'bw-antwort__frage', f.titel)); + paar.append(el('p', 'bw-antwort__text', b.antworten[f.schluessel])); + bogen.append(paar); + } + } + /* WAS NICHT BEANTWORTET WURDE, WIRD GENANNT -- nicht verschwiegen. + Eine freiwillige Frage, die jemand überspringt, ist selbst eine + Auskunft; sie einfach wegzulassen versteckt sie. */ + const offenFragen = daten.fragen.filter((f) => !b.antworten[f.schluessel]); + if (offenFragen.length) { + bogen.append(el('p', 'bw-karte__ohne', + 'Ohne Antwort: ' + offenFragen.map((f) => f.titel).join(' · '))); + } + karte.append(bogen); + + /* ---- Die Entscheidung ---- */ + const wahl = el('div', 'bw-wahl'); + const schild = el('p', 'bw-wahl__schild', 'Was passiert damit?'); + wahl.append(schild); + + const feldkasten = el('div', 'bw-wahl__feld'); + feldkasten.hidden = true; + const feldSchild = el('label', 'bw-wahl__feldschild'); + const feld = document.createElement('textarea'); + feld.className = 'bw-wahl__eingabe'; + feld.rows = 3; + feld.maxLength = 600; + feld.id = 'grund-' + b.id; + feldSchild.htmlFor = feld.id; + feldkasten.append(feldSchild, feld); + + const reihe = el('div', 'bw-wahl__knoepfe'); + let gewaehlt = null; + + const senden = el('button', 'knopf', 'Übernehmen'); + senden.type = 'button'; + senden.hidden = true; + + for (const w of daten.wege) { + const k = el('button', 'bw-weg', w.name); + k.type = 'button'; + k.dataset.weg = w.schluessel; + k.setAttribute('aria-pressed', 'false'); + k.title = w.text || ''; + if (w.schluessel === b.zustand) k.disabled = true; + k.addEventListener('click', () => { + gewaehlt = w; + for (const a of reihe.querySelectorAll('.bw-weg')) { + a.setAttribute('aria-pressed', String(a === k)); + } + /* OB EIN SATZ NÖTIG IST, sagt der Weg selbst -- die Regel + kommt aus derselben Antwort, die der Server prüft. */ + feldkasten.hidden = false; + feldSchild.textContent = w.nachrichtSchild || 'Ein Satz dazu'; + feld.placeholder = w.nachricht === 'pflicht' ? 'nötig' : 'kann leer bleiben'; + senden.hidden = false; + senden.textContent = w.name + ' – übernehmen'; + }); + reihe.append(k); + } + + senden.addEventListener('click', async () => { + if (!gewaehlt) return; + melde(''); + const grund = feld.value.trim(); + if (gewaehlt.nachricht === 'pflicht' && grund.length < (daten.nachricht_min || 10)) { + melde('Dazu gehört ein Satz – er steht danach auf ihrer Seite.'); + feld.focus(); + return; + } + senden.disabled = true; + senden.textContent = 'einen Moment …'; + try { + const a = await fetch('/workspace/api/bewerbung/' + b.id + '/weg', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ weg: gewaehlt.schluessel, grund }), + }); + const d = await a.json().catch(() => ({})); + if (!a.ok) { + melde(d.fehler || 'Das hat nicht geklappt.'); + senden.disabled = false; + senden.textContent = gewaehlt.name + ' – übernehmen'; + return; + } + /* NACH EINER ANNAHME steht der Weg zum Talent da -- und zwar + als Angebot, nicht als Sprung. Wer gerade fünf Anfragen + durchgeht, will nicht nach der ersten auf einer anderen + Seite landen. */ + await laden(); + if (d.weiter) { + const hin = el('p', 'bw-hinweis'); + const a2 = el('a', 'schritt', 'Bei den Talenten ansehen'); + a2.href = d.weiter.replace('/workspace/', ''); + hin.append(document.createTextNode('Daraus wurde ein Talent. ')); + hin.append(a2); + $('liste').prepend(hin); + } + } catch { + melde('Das hat nicht geklappt.'); + senden.disabled = false; + senden.textContent = gewaehlt.name + ' – übernehmen'; + } + }); + + wahl.append(reihe, feldkasten, senden); + karte.append(wahl); + liste.append(karte); + } + }; + + const laden = async () => { + let a; + try { + a = await fetch('/workspace/api/bewerbungen', { credentials: 'same-origin' }); + } catch { + melde('Die Anfragen sind gerade nicht erreichbar.'); + return; + } + if (a.status === 401) { location.assign('/workspace/'); return; } + if (a.status === 404 || a.status === 403) { + $('ladezeile').hidden = true; + melde('Diese Seite ist für DogFather und die rechte Hand.'); + return; + } + if (!a.ok) { melde('Die Anfragen sind gerade nicht erreichbar.'); return; } + daten = await a.json(); + $('ladezeile').hidden = true; + zeichnen(); + }; + + laden(); +})(); diff --git a/workspace/assets/js/talente.js b/workspace/assets/js/talente.js index b8e1f974..818db769 100644 --- a/workspace/assets/js/talente.js +++ b/workspace/assets/js/talente.js @@ -646,7 +646,61 @@ } catch { /* umgeleitet */ } } + /* DIE ANFRAGEN -- nur die Zahl, nicht der Inhalt (17.09.2026). + + Eine eigene, winzige Auskunft. Den vollen Eingang zu laden hiesse, + vertrauliche Saetze in eine Seite zu holen, die sie gar nicht + anzeigt -- im Zwischenspeicher des Browsers landen sie trotzdem. + + SCHWEIGT BEI 404. Wer diese Seite sieht, darf die Anfragen zwar in + aller Regel auch sehen; aber die Rechtetafel ist umstellbar, und + ein Kasten, der dann ins Leere fuehrt, ist schlechter als keiner. */ + async function anfragenZeigen() { + const kasten = $('anfragen-tor'); + if (!kasten) return; + let d; + try { + const a = await hole('/workspace/api/bewerbungen-zahl'); + if (!a.ok) return; + d = await a.json(); + } catch { return; } + + kasten.textContent = ''; + kasten.hidden = false; + if (d.neu > 0) kasten.dataset.neu = 'ja'; else delete kasten.dataset.neu; + + const text = document.createElement('div'); + const wort = document.createElement('p'); + wort.className = 'bw-tor__wort'; + /* DIE ZAHL STEHT IM SATZ, nicht als nackte Ziffer daneben. „2 neue + Anfragen" sagt, was zu tun ist; eine 2 in einem Kreis sagt es + nicht. */ + wort.textContent = d.neu > 0 + ? (d.neu === 1 ? 'Eine neue Anfrage wartet.' : d.neu + ' neue Anfragen warten.') + : (d.offen > 0 + ? (d.offen === 1 ? 'Eine Anfrage ist noch offen.' : d.offen + ' Anfragen sind noch offen.') + : 'Keine offenen Anfragen.'); + text.append(wort); + + const leise = document.createElement('p'); + leise.className = 'leise'; + leise.textContent = 'Wer sich selbst gemeldet hat. Nimmst du an, ' + + 'entsteht daraus hier ein Talent auf der Stufe „Angesprochen“.'; + text.append(leise); + + const hin = document.createElement('a'); + hin.className = d.neu > 0 ? 'knopf' : 'schritt'; + hin.href = 'bewerbungen.html'; + hin.textContent = d.neu > 0 ? 'Ansehen' : 'Anfragen öffnen'; + + kasten.append(text, hin); + } + (async () => { - try { await ichHolen(); await laden(); } catch { /* umgeleitet */ } + try { + await ichHolen(); + await laden(); + await anfragenZeigen(); + } catch { /* umgeleitet */ } })(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 5d674b88..1981296f 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 1ac2c788..7b9ca86b 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index b5684c9e..22474c69 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index dbfd90d6..576815d6 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,24 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + + + - + @@ -188,6 +192,15 @@ Die Regel dahinter: Ein WERKZEUG (fuer das Team) draengt nie eine ANTWORT (fuer alle) nach unten. --> + + +
+ + + + + + + +
+ +

Der Treff · Anfrage

+
+ … + +
+
+ +
+ + + +
+
+

Mitmachen

+ +

Anfrage stellen

+

+ Ein paar Fragen - ehrlich beantwortet ist mehr wert als + vollständig. Du kannst zwischendurch aufhören und später + weitermachen; gespeichert wird erst, wenn du abschickst. +

+
+
+ + + +

einen Moment …

+ + + + + + +
+ + + + + + + + + diff --git a/workspace/bewerbungen.html b/workspace/bewerbungen.html new file mode 100644 index 00000000..d5511af9 --- /dev/null +++ b/workspace/bewerbungen.html @@ -0,0 +1,89 @@ + + + + + +Anfragen · Nachwuchs + + + + + + + + + + + + + + + + + + + +
+ +

Talente · Anfragen

+
+ … + +
+
+ +
+ + + +
+
+

Entwicklung & Nachwuchs

+

Anfragen

+

+ Wer sich selbst gemeldet hat. Nimmst du an, entsteht daraus ein + Eintrag bei den Talenten – auf der Stufe „Angesprochen“, denn wer + sich meldet, hat die erste Frage schon beantwortet. +

+
+
+ + + + + + +

einen Moment …

+ +
+ +
+ + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 684f1f74..a0f92d8a 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 5c3dcc87..25b002e6 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 5bb262d8..6b3b8051 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index d09e1b27..f01e9061 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index b516d14e..016cc275 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609171416", + "src": "/assets/img/app-symbole/crew-192.png?v=202609171558", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609171416", + "src": "/assets/img/app-symbole/crew-512.png?v=202609171558", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171416", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171558", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171416", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171558", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index d2244b69..be38688c 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index ea607ab7..969ea68b 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 648f3e0a..003ec7f6 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 8c97f13b..fc73021f 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 90d1db71..0fe65b3d 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index fc476afd..3cc04fe8 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 957d7c2c..bf91614a 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index dde10f30..2a29027d 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 19850da4..6239d217 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 9caa0c65..46915ba2 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 2342cb9e..5264a3d1 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 88683143..4583f265 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 381875f5..9caf1c1e 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 5ca9024d..4f361248 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,18 @@ - - - - - - - - + + + + + + + + + + @@ -50,6 +54,19 @@ + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index ddae8aa0..16bca3c4 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/teilen.html b/workspace/teilen.html index bb9fbfe5..45acb3ce 100644 --- a/workspace/teilen.html +++ b/workspace/teilen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -112,9 +112,9 @@ - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 9d2e2551..5ca40d19 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 1f931d0f..d5b15cea 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 6053742c..1e9b9499 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 3ff95184..4f226da5 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +