diff --git a/server/pruef-manager-ziele.mjs b/server/pruef-manager-ziele.mjs
index f8309d8c..7badb48f 100644
--- a/server/pruef-manager-ziele.mjs
+++ b/server/pruef-manager-ziele.mjs
@@ -471,6 +471,27 @@ const altNochmal = await schick("scout", `${API}/eintrag/${altId}`, "PATCH",
ok(altNochmal.status === 409,
`der Scout kommt auch danach nicht hinein (${altNochmal.status}) — die Freigabe wirkte nur einmal`);
+/* SPICY MEDIA DARF SEIT DEM 02.10.2026 DASSELBE WIE DOGFATHER.
+ Filipe auf die Rueckfrage, wer korrigieren darf: „ja spicy und
+ dogfather". Geprueft wird beides -- dass Spicy hineinkommt UND
+ dass die Freigabe danach wieder zu ist. Nur das erste zu pruefen
+ hiesse, eine geoeffnete Tuer fuer einen Erfolg zu halten. */
+const altSpicy = await schick("spicy", `${API}/eintrag/${altId}`, "PATCH",
+ { datum: `${VORMONAT}-09`, notiz: "Korrektur durch Spicy Media" });
+ok(altSpicy.status === 200,
+ `Spicy Media darf im Vormonat ebenfalls korrigieren (${altSpicy.status})`);
+const db2b = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const freigabeNachSpicy = db2b.prepare("SELECT offen FROM mz_freigabe WHERE eins = 1").get();
+db2b.close();
+ok(freigabeNachSpicy.offen === 0,
+ `…und die Freigabe ist auch danach wieder zu (${freigabeNachSpicy.offen})`);
+
+/* UND DIE GRENZE HAELT WEITERHIN: Ein Manager ist keine Leitung. */
+const altManager = await schick("manager", `${API}/eintrag/${altId}`, "PATCH",
+ { datum: `${VORMONAT}-10` });
+ok(altManager.status === 403 || altManager.status === 409,
+ `ein Manager kommt dort weiterhin nicht hinein (${altManager.status})`);
+
/* =====================================================================
7 · DIE ZIELZAHLEN
===================================================================== */
@@ -1070,31 +1091,86 @@ ok(mitNamen === gesamtEintraege,
/* =====================================================================
16 · KEIN KNOPF, DER NICHTS TUT
===================================================================== */
-melde("\n=== Bearbeitbar heisst wirklich bearbeitbar ===");
-const spicySicht = await (await hol("spicy",
- `${API}/eintraege?monat=${MONAT}&aufgabe=creator&person=${IDS.scout}`)).json();
-ok(spicySicht.bearbeitbar === false,
- "Spicy Media bekommt an einer FREMDEN Liste keine Bearbeiten-Knoepfe angeboten");
-const dogiSicht = await (await hol("admin",
- `${API}/eintraege?monat=${MONAT}&aufgabe=creator&person=${IDS.scout}`)).json();
-ok(dogiSicht.bearbeitbar === true, "DogFather schon");
+melde("\n=== Wer korrigieren darf (seit 02.10.2026: Spicy UND DogFather) ===");
+
+/* DIE ANGEZEIGTE ERLAUBNIS UND DIE TATSAECHLICHE MUESSEN DIESELBE
+ SEIN. Eine Liste, die „darfst du" sagt und danach 403 liefert, ist
+ schlimmer als eine ohne Knoepfe -- man klickt zweimal, bevor man
+ es glaubt. Deshalb wird hier zu JEDER Anzeige auch der Vollzug
+ gemessen. */
+const sichten = {};
+for (const r of ["spicy", "admin", "manager"]) {
+ sichten[r] = await (await hol(r,
+ `${API}/eintraege?monat=${MONAT}&aufgabe=creator&person=${IDS.scout}`)).json();
+}
+ok(sichten.spicy.bearbeitbar === true,
+ "Spicy Media bekommt an einer fremden Liste Bearbeiten-Knoepfe");
+ok(sichten.admin.bearbeitbar === true, "DogFather ebenfalls");
+/* BEIM MANAGER WAR MEINE ERSTE PRUEFUNG FALSCH, nicht der Code: Er
+ bekommt `bearbeitbar: true` -- weil er gar keine fremde Liste
+ bekommt, sondern seine EIGENE. Die Nummer in der Adresse wird fuer
+ ihn schlicht nicht beachtet. Das ist das richtige Verhalten und
+ die falsche Frage; gemessen gehoert, dass bei ihm nichts Fremdes
+ ankommt. */
+const fremdeIds = new Set(sichten.admin.eintraege.map((e) => e.id));
+ok(fremdeIds.size > 0,
+ `es gibt ueberhaupt fremde Eintraege, die durchsickern koennten (${fremdeIds.size})`);
+ok(!sichten.manager.eintraege.some((e) => fremdeIds.has(e.id)),
+ `ein Manager sieht keinen einzigen davon (${sichten.manager.eintraege.length} eigene)`);
+
const eigeneSicht = await (await hol("scout",
`${API}/eintraege?monat=${MONAT}&aufgabe=creator`)).json();
ok(eigeneSicht.bearbeitbar === true, "und jeder an seiner eigenen");
-/* GEGENPROBE: Die Angabe muss mit dem uebereinstimmen, was der Server
- dann WIRKLICH tut. Eine Anzeige, die „darfst du" sagt und danach
- 403 liefert, ist schlimmer als gar keine. */
-/* EIN EINTRAG, DEN ES WIRKLICH NOCH GIBT. Beim ersten Lauf stand
- hier `einId` -- der war weiter oben geloescht worden, und die
- Antwort lautete 404 statt 403. Das haette wie ein bestandener
- Riegel ausgesehen und war nur „gibt es nicht". */
+/* EIN EINTRAG, DEN ES WIRKLICH NOCH GIBT. Beim ersten Lauf stand hier
+ ein geloeschter -- die Antwort lautete 404 statt 403, und das sah
+ aus wie ein haltender Riegel. */
const lebtNoch = eigeneSicht.eintraege[0]?.id;
-braucht(!!lebtNoch, "es gibt keinen Eintrag, an dem sich die Absage messen liesse");
+braucht(!!lebtNoch, "es gibt keinen Eintrag, an dem sich das messen liesse");
+
const spicyVersuch = await schick("spicy", `${API}/eintrag/${lebtNoch}`, "PATCH",
- { datum: HEUTE, name: "@vonSpicy" });
-ok(spicyVersuch.status === 403,
- `…und der Server weist Spicy auch wirklich ab, mit 403 (${spicyVersuch.status})`);
+ { datum: HEUTE, name: "@vonSpicyKorrigiert" });
+ok(spicyVersuch.status === 200,
+ `Spicy Media korrigiert einen fremden Eintrag wirklich (${spicyVersuch.status})`);
+
+const dbK = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const angekommen = dbK.prepare("SELECT name FROM mz_eintrag WHERE id = ?").get(lebtNoch);
+const vonSpicy = dbK.prepare(
+ `SELECT COUNT(*) n FROM protokoll
+ WHERE aktion = 'manager_ziele_korrigiert' AND rolle = 'spicy'`).get().n;
+dbK.close();
+ok(angekommen?.name === "@vonSpicyKorrigiert",
+ "…und die Aenderung steht wirklich in der Datenbank");
+ok(vonSpicy >= 1,
+ `…und sie ist unter ihrem Namen protokolliert (${vonSpicy} Eintraege)`);
+
+/* GEGENPROBE: Die Grenze liegt bei der Leitung, nicht bei „irgendwer
+ mit einem Zugang". Ohne diese beiden Zeilen hiesse „Spicy darf"
+ moeglicherweise „jeder darf". */
+const managerVersuch = await schick("manager", `${API}/eintrag/${lebtNoch}`, "PATCH",
+ { datum: HEUTE, name: "@vonManager" });
+ok(managerVersuch.status === 403,
+ `ein Manager wird weiterhin abgewiesen (${managerVersuch.status})`);
+const scout2Versuch = await schick("schnell", `${API}/eintrag/${lebtNoch}`, "PATCH",
+ { datum: HEUTE, name: "@vonFremdemScout" });
+ok(scout2Versuch.status === 403,
+ `ein fremder Scout ebenfalls (${scout2Versuch.status})`);
+
+const dbK2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const unveraendert = dbK2.prepare("SELECT name FROM mz_eintrag WHERE id = ?").get(lebtNoch);
+dbK2.close();
+ok(unveraendert?.name === "@vonSpicyKorrigiert",
+ "…und nach beiden Absagen steht immer noch der Wert von Spicy da");
+
+/* Und Loeschen folgt derselben Grenze -- geprueft, nicht angenommen:
+ PATCH und DELETE sind zwei Routen, und zwei Routen koennen
+ auseinanderlaufen. */
+const managerLoeschen = await hol("manager", `${API}/eintrag/${lebtNoch}`, { method: "DELETE" });
+ok(managerLoeschen.status === 403,
+ `ein Manager kann einen fremden Eintrag auch nicht loeschen (${managerLoeschen.status})`);
+const spicyLoeschen = await hol("spicy", `${API}/eintrag/${lebtNoch}`, { method: "DELETE" });
+ok(spicyLoeschen.status === 200,
+ `Spicy Media kann ihn loeschen (${spicyLoeschen.status})`);
/* =====================================================================
SCHLUSS
diff --git a/server/workspace-manager-ziele.js b/server/workspace-manager-ziele.js
index 037011b2..a1dd84ed 100644
--- a/server/workspace-manager-ziele.js
+++ b/server/workspace-manager-ziele.js
@@ -19,6 +19,12 @@
gleichzeitig es immer ändern können für alle"). Ein Scout muss
nicht dieselbe Zahl schaffen wie DogFather.
+ DASSELBE GILT SEIT DEM 02.10.2026 FÜRS KORRIGIEREN. Die Vorlage
+ kannte dort nur DogFather; auf die Rückfrage: „ja spicy und
+ dogfather". Beides liegt jetzt auf derselben Menge
+ (`siehtAlles`) — wer alle Zahlen sieht und die Ziele setzt, muss
+ auch einen Zahlendreher gerade rücken können.
+
3. DIE PIPELINE IST ANGEBUNDEN, in beide Richtungen. Die Vorlage
kennt nur die Handeingabe. Das Haus hat aber ein vollständiges
Scout-CRM (`leads`), und zwei Listen derselben Rekrutierungen
@@ -151,6 +157,29 @@ export function stufenText(stufe, bisEnde) {
return "";
}
+/** Wer fremde Eintraege korrigieren und abgeschlossene Monate oeffnen
+ * darf.
+ *
+ * BEIDE ROLLEN, NICHT NUR DOGFATHER (02.10.2026). Die Vorlage sagte
+ * „Ausnahme: DogFather"; Filipe auf die Rueckfrage: „ja spicy und
+ * dogfather". Damit gilt hier dieselbe Menge wie bei den Zielzahlen
+ * und bei der Team-Uebersicht -- und das ist der eigentliche Grund,
+ * es so zu machen: Wer die Zahlen aller sieht und die Ziele setzt,
+ * muss auch einen Zahlendreher gerade ruecken koennen. Zwei
+ * verschiedene Grenzen fuer „darf alles sehen" und „darf etwas
+ * richtigstellen" waeren die Stelle, an der sich spaeter niemand
+ * mehr erklaeren kann, warum ein Knopf fehlt.
+ *
+ * ES IST `siehtAlles` UND KEINE EIGENE LISTE. Eine zweite Aufzaehlung
+ * derselben zwei Rollen waere die, die beim naechsten Umbau
+ * auseinanderlaeuft.
+ *
+ * UND ES BLEIBT NACHVOLLZIEHBAR: Jede Korrektur an einem fremden
+ * Eintrag und jede Aenderung an einem abgeschlossenen Monat steht
+ * mit Name und Zeit im Protokoll -- unveraendert, auch jetzt, wo es
+ * zwei Menschen tun koennen statt einem. */
+const darfKorrigieren = (person) => siehtAlles(person);
+
const RANG = { rot: 0, orange: 1, neutral: 2, gruen: 3, aus: 4 };
/** Die schlechteste Stufe einer Liste — das ist die Farbe der Kachel. */
export function schlechteste(stufen) {
@@ -528,9 +557,10 @@ function dublette(personId, monat, werte, ausser = 0) {
DIE FREIGABE FÜR ABGESCHLOSSENE MONATE
=====================================================================
- Nur DogFather, nur für eine einzelne Handlung, nur zwei Minuten.
- Die Vorlage sagt „Vergangene Monate sind gesperrt und nicht mehr
- bearbeitbar (Ausnahme: DogFather)" — und der Trigger in der
+ Nur die Leitung (DogFather und Spicy Media), nur für eine einzelne
+ Handlung, nur zwei Minuten. Die Vorlage sagt „Vergangene Monate
+ sind gesperrt und nicht mehr bearbeitbar (Ausnahme: DogFather)";
+ seit dem 02.10.2026 gilt sie für beide — und der Trigger in der
Datenbank kann keine Sitzung lesen. Also wird die Ausnahme ein
sichtbarer Vorgang: Schloss auf, Handlung, Schloss zu, protokolliert.
@@ -577,7 +607,7 @@ managerZieleRouter.get(`${BASIS}/stand`, (req, res) => {
rollenName: ROLLEN_NAME[person.rolle] || person.rolle },
eigen: person.id === req.person.id,
darfZiele: siehtAlles(req.person),
- darfKorrigieren: req.person.rolle === "admin",
+ darfKorrigieren: darfKorrigieren(req.person),
erster: ERSTER_MONAT,
jetztMonat: monatJetzt(),
});
@@ -618,8 +648,8 @@ managerZieleRouter.get(`${BASIS}/eintraege`, (req, res) => {
Spicy Media an einer fremden Liste „Bearbeiten" und
„Löschen" stehen, und der Server antwortete mit 403 — ein
Knopf, der nichts tut, ist schlimmer als kein Knopf. */
- bearbeitbar: (personId === req.person.id || req.person.rolle === "admin")
- && (monat === monatJetzt() || req.person.rolle === "admin"),
+ bearbeitbar: (personId === req.person.id || darfKorrigieren(req.person))
+ && (monat === monatJetzt() || darfKorrigieren(req.person)),
});
} catch (fehler) {
console.error("[manager-ziele] Eintraege:", fehler?.message);
@@ -814,8 +844,8 @@ managerZieleRouter.patch(`${BASIS}/eintrag/:id`, (req, res) => {
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
- const istAdmin = req.person.rolle === "admin";
- if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" });
+ const darfEr = darfKorrigieren(req.person);
+ if (fremd && !darfEr) return res.status(403).json({ fehler: "nicht_deins" });
const heute = tagBerlin();
const gepruefte = eintragPruefen({ ...req.body, aufgabe: alt.aufgabe }, alt.monat, heute);
@@ -834,7 +864,7 @@ managerZieleRouter.patch(`${BASIS}/eintrag/:id`, (req, res) => {
jetzt(), req.person.id, id);
const altMonat = alt.monat !== monatJetzt();
- if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" });
+ if (altMonat && !darfEr) return res.status(409).json({ fehler: "monat_gesperrt" });
if (altMonat) mitFreigabe(schreiben); else schreiben();
if (fremd || altMonat) {
@@ -864,11 +894,11 @@ managerZieleRouter.delete(`${BASIS}/eintrag/:id`, (req, res) => {
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
- const istAdmin = req.person.rolle === "admin";
- if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" });
+ const darfEr = darfKorrigieren(req.person);
+ if (fremd && !darfEr) return res.status(403).json({ fehler: "nicht_deins" });
const altMonat = alt.monat !== monatJetzt();
- if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" });
+ if (altMonat && !darfEr) return res.status(409).json({ fehler: "monat_gesperrt" });
const loeschen = () => db().prepare("DELETE FROM mz_eintrag WHERE id = ?").run(id);
if (altMonat) mitFreigabe(loeschen); else loeschen();
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 4a0d9f8f..11069850 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+