Filipe, 24.09.2026: "wenn ich bei der einen was mache soll nichts bei
der anderen passieren." -- und heute: "ja los".
GEMESSEN, BEVOR ETWAS ANGEFASST WURDE (jedes Brett x beide Adressen x
vier Rollen):
AGENTUR-Adresse DogFather 18 Bretter
Manager 6 Bretter
Creator 6 Bretter
Modi kommt nicht rein (401)
CREW-Adresse DogFather 18 Bretter
Modi 13 Bretter
Manager kommt nicht rein (401)
Creator kommt nicht rein (401)
Die Anmeldung war also dicht. Durch griff genau EINE Rolle: DogFather.
Er wohnt in beiden Haeusern, und die Riegel in sichtbarEintrag()
fragten nach der ROLLE, nicht nach der Adresse. Auf der Crew-Adresse
stand damit das Brett der Agentur samt Inhalt.
Nachgemessen ist der Durchgriff AELTER als der gestrige Eventkarten-
Umbau -- zweimal gemessen, mit und ohne ihn, gleiches Ergebnis.
RIEGEL 0 in sichtbarEintrag(): Wer auf einer Adresse angemeldet ist,
sieht nur Eintraege dieses Hauses. Er haengt den uebrigen Riegeln
UM, statt in jeden Ausgang geschrieben zu werden -- die Funktion hat
drei Rueckgabepunkte, und der naechste waere sonst wieder offen.
Nachher, dieselbe Messung: DogFather sieht auf der Agenturadresse nur
Agentur-Eintraege, auf der Crew-Adresse nur die des Rudels. Manager,
Creator und Modi unveraendert.
WAS DABEI SCHIEFGING UND WIE ES AUFFIEL
1. Die erste Fassung liess bei `haus IS NULL` den BEREICH entscheiden.
pruef-haus-trennung.mjs wurde sofort rot: "Lunas Live vom Montag"
verschwand von der Agenturadresse. `live`, `technik` und
`community` tragen BEIDES -- die Kacheln von Team Dogi und die
Creator-Akten. Eine Regel, die jedem Brett genau ein Haus zuweist,
kann das nicht. Jetzt bleibt ein Eintrag ohne Haus sichtbar: ein
Eintrag zu viel faellt auf, ein fehlender nicht.
2. Damit NULL kein Dauerloch ist: FUENF von ACHT Stellen, die
Eintraege anlegen, setzten `haus` gar nicht (workspace-video.js,
-content.js, -bewerbung.js, -treff.js, -vorlagen.js). Nachgetragen.
3. Und `person.haus` war dafuer der falsche Massstab: Legt DogFather
ueber die Agenturadresse ein Highlight an, gehoert es trotzdem dem
Rudel -- sonst sieht die Community es nie. pruef-treff.mjs hat das
gefunden (2 Fehler). Neu: hausFuerNeuenEintrag() -- bei den sieben
Brettern des Rudels entscheidet das BRETT, sonst die Adresse.
NEU: pruef-haus-luecke.mjs (12 Pruefungen). Sie sucht die
Einfuege-Stellen im Quelltext und wird rot, sobald eine neunte
dazukommt, die `haus` vergisst -- mit Gegenprobe, dass das Suchmuster
eine solche Stelle auch wirklich erkennt. Ein Kommentar daneben haette
es nicht verhindert; das steht so schon im Projektgedaechtnis.
NEBENBEI: In bereich.js stand seit gestern `|| "Agentur-Events"` als
Rueckfall fuer das Etikett der Vorschau. pruef-treff.mjs verbietet
das zu Recht -- wie ein Brett heisst, haengt am Haus, und der Server
sagt es. Der Rueckfall ist weg; fehlt die Angabe, steht lieber kein
Etikett da als ein falsches.
pruef-treff.mjs nachgezogen (85 -> 86 Pruefungen, nicht weniger): Die
Zusage "DogFather sieht den Beitrag" wird jetzt auf der Crew-Adresse
geprueft, mit Gegenprobe fuer die Agenturadresse.
GEPRUEFT, alle gruen:
pruef-haus-luecke 12 pruef-haus-trennung 100
pruef-crew-adresse 169 pruef-treff 86
pruef-eventkarte 77 pruef-agentur 62
pruef-haus-seiten 38 pruef-eintrag-bild 24
pruef-vorlagen 24 pruef-video, -content, -bewerbung,
-bereiche-lesend: in Ordnung
Co-Authored-By: Claude Opus 5 <[email protected]>
590 lines
26 KiB
JavaScript
590 lines
26 KiB
JavaScript
/* =====================================================================
|
||
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,
|
||
hausFuerNeuenEintrag,
|
||
} 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. */
|
||
/* =====================================================================
|
||
WER SICH KUEMMERT (17.09.2026)
|
||
|
||
Filipe wollte die Arbeit mit der rechten Hand teilen. Bis heute
|
||
gab es dafuer nichts: Beide sehen jede Anfrage, niemand ist
|
||
benannt. Das ist nicht „geteilt", das ist „jeder dachte, der
|
||
andere macht es" -- und bei einer Anfrage wartet ein Mensch
|
||
darauf.
|
||
|
||
MAN NIMMT SELBST, MAN BEKOMMT NICHT ZUGETEILT. Ein „DogFather
|
||
weist zu" waere eine Rangordnung, und die gehoert hier nicht hin
|
||
(Filipe, 18.08.2026: „mich bitte nie wie da hoeher stellen wie
|
||
andere"). Wer Zeit hat, uebernimmt; wer keine mehr hat, gibt ab.
|
||
|
||
ALS SPALTE UND NICHT ALS EIGENE TABELLE: Es ist genau eine Zahl je
|
||
Anfrage. Eine Tabelle dafuer waere ein Verbund mehr bei jeder
|
||
Abfrage und keine einzige zusaetzliche Antwort.
|
||
|
||
`ON DELETE SET NULL`: Geht jemand, faellt seine Anfrage zurueck in
|
||
„kuemmert sich niemand" -- sichtbar, statt an einen Namen gebunden
|
||
zu bleiben, den es nicht mehr gibt. */
|
||
for (const [spalte, typ] of [
|
||
["zustaendig_id", "INTEGER"],
|
||
["zustaendig_seit", "TEXT"],
|
||
]) {
|
||
try {
|
||
const da = db().prepare("PRAGMA table_info(bewerbungen)").all().map((z) => z.name);
|
||
if (!da.includes(spalte)) {
|
||
db().exec(`ALTER TABLE bewerbungen ADD COLUMN ${spalte} ${typ}`);
|
||
console.log(`[anfragen] Spalte '${spalte}' ergaenzt.`);
|
||
}
|
||
} catch (f) { console.error(`[anfragen] Spalte '${spalte}':`, f?.message); }
|
||
}
|
||
|
||
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);
|
||
/* =====================================================================
|
||
ICH KUEMMERE MICH (17.09.2026)
|
||
|
||
EIN KNOPF, ZWEI RICHTUNGEN -- wie bei den Merkmalen und den
|
||
Probeschritten. Wer schon zustaendig ist, gibt mit demselben Knopf
|
||
wieder ab. Zwei getrennte Wege waeren zwei Knoepfe, von denen immer
|
||
einer nichts tut.
|
||
|
||
MAN NIMMT SELBST, MAN BEKOMMT NICHT ZUGETEILT. Ein „DogFather weist
|
||
zu" waere eine Rangordnung, und die gehoert hier nicht hin. Wer Zeit
|
||
hat, uebernimmt; wer keine mehr hat, gibt ab.
|
||
|
||
EINE FREMDE UEBERNAHME WIRD ABGEWIESEN, nicht stillschweigend
|
||
ueberschrieben: Sonst nimmt einer dem anderen die Anfrage aus der
|
||
Hand, ohne dass es jemand merkt -- und beide glauben, sie sei
|
||
erledigt. Wer wirklich uebernehmen will, laesst erst abgeben.
|
||
===================================================================== */
|
||
bewerbungRouter.put("/workspace/api/bewerbung/:id/kuemmern", gleicheHerkunft,
|
||
/* `nurLeitung` GEHOERT HIERHIN und ist beim Schreiben gefehlt.
|
||
|
||
Der Router setzt oben `angemeldet` fuer den ganzen Pfad, aber NICHT
|
||
`nurLeitung` -- das haengt an jeder einzelnen Route (siehe die
|
||
beiden darunter). Ohne diese Zeile haette sich jeder Angemeldete
|
||
fuer eine fremde Anfrage zustaendig erklaeren koennen: kein
|
||
Datenabfluss, aber ein Name an einer Anfrage, der dort nichts zu
|
||
suchen hat -- und die Anfrage gilt als betreut, waehrend sich
|
||
niemand kuemmert. Gefunden beim Nachsehen der Nachbarzeilen. */
|
||
nurLeitung, (req, res) => {
|
||
try {
|
||
/* `Number` und eine eigene Pruefung -- diese Datei hat keinen
|
||
`nummer`-Helfer, und ein Aufruf darauf waere ein Absturz zur
|
||
Laufzeit gewesen (node --check sieht das nicht). Gefunden beim
|
||
Nachsehen, nicht beim Schreiben. */
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id) || id <= 0) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
const b = id && db().prepare(
|
||
"SELECT id, zustaendig_id FROM bewerbungen WHERE id = ?").get(id);
|
||
if (!b) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
if (b.zustaendig_id && b.zustaendig_id !== req.person.id) {
|
||
const wer = db().prepare("SELECT name FROM personen WHERE id = ?")
|
||
.get(b.zustaendig_id);
|
||
return res.status(409).json({
|
||
fehler: `Darum kümmert sich schon ${wer?.name || "jemand"}.`,
|
||
});
|
||
}
|
||
|
||
const nehmen = !b.zustaendig_id;
|
||
db().prepare("UPDATE bewerbungen SET zustaendig_id = ?, zustaendig_seit = ? WHERE id = ?")
|
||
.run(nehmen ? req.person.id : null, nehmen ? jetzt() : null, id);
|
||
protokolliere(nehmen ? "anfrage_uebernommen" : "anfrage_abgegeben", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id}`,
|
||
});
|
||
res.json({ ok: true, zustaendig: nehmen });
|
||
} catch (f) {
|
||
console.error("[anfragen] Kuemmern:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
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,
|
||
/* WER SICH KUEMMERT -- oder niemand. Die zweite Antwort ist
|
||
die wichtigere: Eine Anfrage, um die sich niemand
|
||
kuemmert, soll das sagen. */
|
||
b.zustaendig_id, b.zustaendig_seit, z.name AS zustaendig_name,
|
||
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
|
||
LEFT JOIN personen z ON z.id = b.zustaendig_id
|
||
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, haus)
|
||
VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?, ?)`)
|
||
.run(b.nenn_name,
|
||
`Hat sich selbst gemeldet (Anfrage #${id}).`
|
||
+ (antworten.get("warum") ? `\n\nWarum: ${antworten.get("warum")}` : ""),
|
||
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */
|
||
heuteLokal(), t, req.person.id, einsatz, hausFuerNeuenEintrag(req.person, "talente"));
|
||
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" });
|
||
}
|
||
});
|