Files
dogfather-universe/server/workspace-zuteilung.js
T
DogFatherGitandClaude Opus 5 731537b682 Eine Wahrheit statt zwei: Der Status der Aufgabe gilt
VanVan im Support (Runde 1, „Ging noch nicht"): „Das dort steht ich
bewerbe mich ist jetzt weg, aber dafür hat er noch mal 2 Buttons
hinzugefügt mit ich fange an und fertig. Wenn man auf ich fange an
drückt steht dort in Bearbeitung und wenn man auf fertig drückt dann
wird es zu erledigt. Die Aufgabe bleibt aber im status offen stehen.
Die beiden Buttons können entfernt werden, weil es darüber ja den
button starten gibt, der auch korrekt funktioniert."

SIE HAT ETWAS GROESSERES GEFUNDEN ALS ZWEI UEBERFLUESSIGE KNOEPFE.

Es gab ZWEI Zustaende nebeneinander, und sie kannten sich nicht:

    aufgaben.status             offen · arbeit · review · erledigt
    aufgaben_zuteilung.zustand  angenommen · arbeit · erledigt

Die zwei Knoepfe setzten den zweiten (`/mein-stand`), der
Starten-Knopf den ersten. Auf der Karte stand „in Bearbeitung", in der
Liste „offen" -- und beides stimmte. Das ist schlimmer als ein Fehler:
Es gibt nichts, dem man glauben kann. Zwei Antworten auf dieselbe
Frage sind in diesem Haus verboten, und genau das war es.

WAS ICH BEINAHE FALSCH GEMACHT HAETTE

Ihr Wunsch war „entfernt die Knoepfe". Bevor ich das tue, habe ich
gemessen, was danach bliebe -- am Bildschirm einer Modi mit einer
angenommenen Aufgabe:

    Karten-Knoepfe:  []
    Schritt-Knoepfe: []

KEIN EINZIGER. Die Modi sieht den Starten-Knopf NICHT, weil
`darfAendern` fuer sie falsch ist: Sie ist weder Leitung noch
`creator_id`, `verantwortlich_id` oder `erstellt_von` -- die Zuteilung
laeuft ueber eine eigene Tabelle. VanVan ist Leitung und sieht ihn;
deshalb klang „den gibt es doch" selbstverstaendlich.

Haette ich die Knoepfe einfach geloescht, haette ich der Modi die
einzige Handlung weggenommen, die sie hatte -- eine Meldung „behoben",
nach der weniger geht als vorher.

ALSO WIRD IHR SATZ WAHR GEMACHT

 1. Wer eine Aufgabe WIRKLICH hat (angenommen/arbeit/erledigt), darf
    ihren STATUS setzen. Damit sieht die Modi denselben Knopf wie alle
    -- gemessen: „Schritt-Knoepfe: [starten ▶]".

    ENG GEFASST: nur der Status, nur allein in der Anfrage. Die
    Pruefung ist `Object.keys(...).length === 1` und nicht „enthaelt
    status" -- sonst waere die schmale Tuer die breite mit einem
    Zusatzfeld.

 2. Der Statuswechsel zieht die Zuteilung MIT. Ohne das waere das
    Entfernen eine stille Verschlechterung gewesen: Die Zaehler einer
    Person („offen / in Arbeit / erledigt") lesen die ZUTEILUNG, nicht
    die Aufgabe. Jede Zuteilung waere fuer immer auf „angenommen"
    stehen geblieben, und die Zahlen haetten aufgehoert, die
    Wirklichkeit zu zeigen -- ohne dass irgendwo etwas rot wird.

    ABGELEITET, NICHT ZWEIMAL GESCHRIEBEN: `STATUS_ALS_ZUSTAND` gibt
    es seit dem 22.09. Benutzt wird genau sie, mit EINER Abweichung,
    und die steht daneben: Wer zugesagt hat, faellt beim Zurueckdrehen
    auf „angenommen", nicht auf „offen". Eine Zusage verschwindet
    nicht, weil jemand den Status zurueckstellt.

 3. Die zwei Knoepfe sind weg. Der Weg `/mein-stand` bleibt -- er ist
    die Schranke fuer den, der die Schnittstelle direkt anspricht.

GEGENPROBEN ZUM ERWEITERTEN RECHT (ein Recht ohne Gegenprobe ist ein
Loch mit Begruendung):

    Anna hat eine Aufgabe, die ihr NUR zugeteilt ist
      (darf_aendern false, darf_status true)
    sie setzt den Status ihrer Aufgabe (200)
    mit einem zweiten Feld kommt sie nicht durch (403)
    und umschreiben darf sie gar nicht (403)
    der Titel steht unveraendert da („Clips schneiden")
    und wer sie nicht hat, setzt auch keinen Status (404)
    zurueckgedreht steht Anna wieder auf „angenommen"

    eine Bewerbung bleibt eine Bewerbung (abgelehnt -> abgelehnt)

ZWEI EIGENE FEHLER, BEIDE VON DER MESSUNG GEFUNDEN:

 · Mein erster Zeuge war Bea und die Pool-Aufgabe. Die Gegenprobe
   wurde rot: Bea darf sie ohnehin umschreiben, weil das Uebernehmen
   aus dem Pool sie verantwortlich macht. An ihr laesst sich ueber die
   neue, schmale Tuer gar nichts zeigen. Der reine Fall wird jetzt
   GESUCHT (darf_aendern falsch, Zuteilung angenommen) statt
   hingeschrieben -- eine feste Nummer waere die naechste, die beim
   naechsten Umbau nicht mehr stimmt.
 · Mein Abschnitt stellte Annas Aufgabe auf „arbeit" und liess sie so
   stehen; ein spaeterer zaehlte ihre „angenommen" und wurde dadurch
   rot. Eine Pruefung, die den Bestand fuer die naechste veraendert,
   misst ab da etwas anderes als sie glaubt. Jetzt raeumt sie auf --
   und die Rueckfahrt ist selbst eine Messung.

ZWEI PRUEFUNGEN MUSSTEN MITZIEHEN, und das ist richtig so: Beide
verlangten „Ich fange an" -- geschrieben von mir am 30.09. fuer
VanVans ERSTE Meldung. Ihre Absicht bleibt woertlich dieselbe („kann
sie wirklich etwas tun?"), nur ist der Griff jetzt der Statusknopf.
`knoepfeAn` sieht dafuer auch neben den Zuteilungsblock: „kann sie
etwas tun?" laesst sich am Block allein nicht beantworten.

GEPRUEFT: pruef-zuteilung 75 -> 90 ok · pruef-bewerbung-aufgaben
162 -> 164 ok · pruef-struktur 99 · pruef-resuemee 35 ·
pruef-aufgabenbrett 49 · pruef-rechtetafel 19 ·
pruef-aufgaben-vorlagen 46.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 15:11:35 +02:00

1072 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
AUFGABEN AN MENSCHEN — ZUTEILEN, ANNEHMEN, ABLEHNEN, BEWERTEN
=====================================================================
Auftrag vom 21.09.2026, Abschnitte 2 bis 5.
WAS DIESES MODUL BEANTWORTET — und was es bewusst NICHT tut:
Es beantwortet die Frage "wie steht diese Aufgabe bei DIESEM
Menschen". Die Frage "wie steht die Aufgabe" beantwortet weiterhin
`aufgaben.status` in workspace-aufgaben.js. Zwei Fragen, zwei Orte —
ein einziges Feld könnte bei drei Leuten nicht gleichzeitig
"angenommen" und "abgelehnt" sein.
DREI ARTEN ZU VERTEILEN, und der Unterschied zwischen den letzten
beiden ist der ganze Punkt:
einzeln eine Person, sie macht es
mehrere mehrere Personen, JEDE macht ihren Teil
pool mehrere sehen es, EINE nimmt es — danach ist es für die
anderen erledigt, damit niemand doppelt arbeitet
WER DARF WAS:
verteilen DogFather, rechte Hand, linke Hand
(darfAufgabenVerteilen — eine Quelle, nicht drei)
annehmen nur, wer sie bekommen hat
ablehnen dito, mit Begründung — Pflicht, siehe unten
übernehmen nur bei "pool", nur wer angeboten bekommen hat,
und nur solange niemand sonst sie genommen hat
bewerten nur wer verteilen darf, und erst wenn erledigt
WARUM DIE BEGRÜNDUNG BEIM ABLEHNEN PFLICHT IST: Filipe schreibt
"soll die Modi die Möglichkeit haben, eine Begründung bzw. Nachricht
zu hinterlassen". Ein leeres Nein ist für den, der die Aufgabe
verteilt hat, keine Information — er muss dann nachfragen, und genau
das sollte die Nachricht ersparen. Deshalb: mindestens ein paar
Zeichen. Wer wirklich nichts sagen will, schreibt "keine Zeit" —
auch das ist mehr als nichts.
WAS MIT `verantwortlich_id` PASSIERT (die alte Spalte):
einzeln = die eine Person
pool = leer, bis jemand übernimmt — danach der Übernehmer
mehrere = leer
Sie bleibt, weil das Aufgabenbrett, die Startseite und die Übersicht
danach gruppieren. Sie abzuschaffen hieße, drei gewachsene Ansichten
gleichzeitig umzubauen; sie stehen zu lassen und daneben die
Zuteilung zu führen ist der kleinere Eingriff — und bei "einzeln",
also dem häufigsten Fall, sagen beide dasselbe.
===================================================================== */
import express from "express";
import {
db, protokolliere, echteIp, darfAufgabenVerteilen, entscheidetUeberAufgaben,
istDogFather, istHand,
sitzungLesen,
heuteLokal,
} from "./workspace.js";
import { TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
/* WER ES ERFAEHRT -- an einer Stelle fuer beide Bewerbungswege.
Begruendung samt Messung steht in der Datei selbst. */
import {
meldeBewerbung, meldeBewerbungsantwort,
} from "./workspace-bewerbung-melden.js";
export const zuteilungRouter = express.Router();
/* DIESELBE SCHRANKE WIE BEIM AUFGABENBRETT, und sie haengt UEBER allem
in diesem Modul: Wer nicht angemeldet ist, kommt an keine dieser
Routen. Eine Schranke je Route waere die Stelle, an der die naechste
Route sie vergisst.
`express.json` mit kleiner Grenze -- hier kommen Begruendungen und
Rueckmeldungen herein, keine Dateien. */
/* EIGENE SCHRANKE, KEIN IMPORT AUS workspace-aufgaben.js: Die Datei
dort holt sich gleich `zuteilen` und `mitZuteilung` von hier. Ein
Import in beide Richtungen waere ein Ringschluss -- der faellt in
Node nicht sofort auf, sondern erst, wenn eine der beiden Dateien
beim Laden auf etwas zugreift, das die andere noch nicht gesetzt
hat. Das sind vier Zeilen, die dort auch stehen; hier ist die
Dopplung billiger als der Ring. */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
zuteilungRouter.use("/workspace/api", angemeldet);
zuteilungRouter.use("/workspace/api", express.json({ limit: "8kb" }));
const jetzt = () => new Date().toISOString();
/** Die drei Arten. Als Menge, damit eine erfundene Art nicht
* stillschweigend durchrutscht und als NULL in der Tabelle landet. */
export const VERTEILARTEN = new Set(["einzeln", "mehrere", "pool"]);
/** Die Zustände eines Menschen zu einer Aufgabe, in der Reihenfolge,
* in der sie im Alltag durchlaufen werden. */
export const ZUSTAENDE = ["offen", "angenommen", "arbeit", "erledigt", "abgelehnt"];
/** Die drei Rückmeldungen aus Abschnitt 5. */
export const BEWERTUNGEN = new Set(["gut", "nicht_gut", "verbessern"]);
/** Klartext für die Oberfläche. Sie soll die Wörter nicht selbst
* erfinden — sonst heißt derselbe Zustand an zwei Stellen anders. */
export const ZUSTAND_TEXT = {
offen: "Noch nicht beantwortet",
/* „Beworben" ist kein halbes „Angenommen", sondern ein eigener
Zustand: Es liegt an jemand anderem, nicht mehr an der Person
selbst. Der Satz sagt das, statt es offen zu lassen. */
beworben: "Beworben – wartet auf Antwort",
angenommen: "Angenommen",
arbeit: "In Bearbeitung",
erledigt: "Erledigt",
abgelehnt: "Abgelehnt",
};
export const BEWERTUNG_TEXT = {
gut: "Gut gemacht",
nicht_gut: "Nicht gut gemacht",
verbessern: "Verbesserungsmöglichkeiten",
};
/* =====================================================================
LESEN
===================================================================== */
/** Alle Zuteilungen zu einer Menge von Aufgaben — in EINER Abfrage.
*
* EINE ABFRAGE UND NICHT EINE JE AUFGABE: Bei vierzig Aufgaben auf
* dem Brett wären das vierzig Abfragen, und die Seite würde spürbar
* langsamer, ohne dass jemand sähe, warum. */
export function zuteilungenZu(aufgabeIds) {
const ids = [...new Set((aufgabeIds || []).map(Number).filter(Number.isInteger))];
if (!ids.length) return new Map();
const platz = ids.map(() => "?").join(",");
const reihen = db().prepare(`
SELECT z.id, z.aufgabe_id, z.person_id, z.zustand, z.grund,
z.zugeteilt_am, z.geantwortet_am, z.erledigt_am,
z.bewertung, z.bewertung_text, z.bewertet_am,
z.entscheid_text, z.entschieden_am,
p.name AS person_name, p.rolle AS person_rolle,
pb.name AS bewertet_von_name,
pe.name AS entschieden_von_name
FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
LEFT JOIN personen pb ON pb.id = z.bewertet_von
LEFT JOIN personen pe ON pe.id = z.entschieden_von
WHERE z.aufgabe_id IN (${platz})
ORDER BY p.name COLLATE NOCASE`).all(...ids);
const karte = new Map();
for (const z of reihen) {
if (!karte.has(z.aufgabe_id)) karte.set(z.aufgabe_id, []);
karte.get(z.aufgabe_id).push({
...z,
zustand_text: ZUSTAND_TEXT[z.zustand] || z.zustand,
bewertung_text_kurz: z.bewertung ? BEWERTUNG_TEXT[z.bewertung] : null,
});
}
return karte;
}
/** Hängt die Zuteilungen an eine Liste von Aufgabenzeilen.
*
* Dazu je Zeile drei abgeleitete Angaben, damit die Oberfläche nicht
* dieselbe Rechnung noch einmal macht — zwei Rechnungen für dieselbe
* Frage laufen auseinander. */
export function mitZuteilung(reihen, ich) {
const liste = Array.isArray(reihen) ? reihen : [];
const karte = zuteilungenZu(liste.map((r) => r.id));
for (const r of liste) {
const zu = karte.get(r.id) || [];
r.zuteilung = zu;
r.verteilart = r.verteilart || (zu.length > 1 ? "mehrere" : (zu.length ? "einzeln" : null));
/* MEIN eigener Stand — das ist die Angabe, aus der die persönliche
Ansicht eines Modi entsteht. */
r.meine_zuteilung = ich ? (zu.find((z) => z.person_id === ich.id) || null) : null;
/* BEI EINER POOL-AUFGABE: Hat sie schon jemand? Der Auftrag
verlangt ausdrücklich, dass die anderen das erkennen, "damit
keine doppelte Bearbeitung entsteht". */
r.uebernommen_von = r.verteilart === "pool"
? (zu.find((z) => z.zustand !== "offen" && z.zustand !== "abgelehnt"
&& z.zustand !== "beworben")?.person_name || null)
: null;
/* DIE OFFENEN BEWERBUNGEN, abgeleitet statt danebengefuehrt.
Die Oberflaeche soll nicht dieselbe Rechnung noch einmal machen
-- zwei Rechnungen fuer dieselbe Frage laufen auseinander. */
/* NACH EINGANG SORTIERT, NICHT NACH NAMEN (22.09.2026).
Filipe zu meinem eigenen Satz „wer zuerst Zeit hat — genau das
geht ja nicht mehr": „falls das nicht mehr geht mach das es
geht wenn es wieder geht ist alles gut."
Er hat recht, und ich hatte es zu schnell aufgegeben. „Wer
zuerst Zeit hat" muss nicht heissen, dass man sich selbst
bedient -- es kann genauso heissen, dass die ERSTE BEWERBUNG
zuerst drankommt. Damit gilt beides: Die Leitung entscheidet
(sein Wunsch von vorhin), und wer schnell ist, hat den Vorteil
(sein Wunsch von eben).
Die Abfrage weiter oben sortiert nach NAMEN -- gut fuer die
Liste der Zugeteilten, falsch hier: Nach Alphabet gaebe es
keinen Vorteil fuer den Schnellen, sondern einen fuer Anna.
`geantwortet_am` ist der Zeitpunkt der Bewerbung (beim
Bewerben gesetzt). Gleichstand gibt es praktisch nicht; falls
doch, entscheidet die laufende Nummer -- irgendeine Ordnung
ist besser als eine zufaellige. */
r.bewerbungen = zu.filter((z) => z.zustand === "beworben")
.sort((x, y) => String(x.geantwortet_am || "").localeCompare(String(y.geantwortet_am || ""))
|| (x.id - y.id));
/* UND OB ICH MICH UEBERHAUPT NOCH BEWERBEN KANN. Ein Knopf, der
beim Druecken eine Absage bringt, ist schlimmer als keiner. */
r.ich_beworben = !!(r.meine_zuteilung
&& r.meine_zuteilung.zustand === "beworben");
}
return liste;
}
/* =====================================================================
SCHREIBEN — die eine Stelle, an der zugeteilt wird
===================================================================== */
/** Legt die Zuteilungen einer Aufgabe an (oder schreibt sie neu).
*
* Gibt zurück, was wirklich gesetzt wurde — der Aufrufer soll nicht
* raten müssen, ob seine Wünsche durchgekommen sind. */
export function zuteilen(aufgabeId, personIds, art, vonId) {
const id = Number(aufgabeId);
if (!Number.isInteger(id)) return { fehler: "ungueltige_aufgabe" };
const roh = [...new Set((personIds || []).map(Number).filter(Number.isInteger))];
if (!roh.length) return { gesetzt: [], art: null };
/* NUR AKTIVE MENSCHEN. Eine Aufgabe an einen gesperrten Zugang zu
hängen heißt: Sie steht im Resümee und niemand kann sie machen. */
const platz = roh.map(() => "?").join(",");
const echt = db().prepare(
`SELECT id FROM personen WHERE id IN (${platz}) AND aktiv = 1`).all(...roh).map((z) => z.id);
if (!echt.length) return { fehler: "keine_person" };
/* NIEMAND TRAEGT SICH SELBST EIN, DER NICHT ENTSCHEIDEN DARF
(22.09.2026).
Ohne diese Zeile waere das Verbot von oben ein Umweg mit einem
Klick mehr: Die linke Hand darf verteilen -- sie haette sich die
Aufgabe also einfach selbst zugeteilt und damit genau das getan,
was Filipe ausgeschlossen hat („keine aufgaben selber uebernehmen
die ihnen nicht zugetragen sind").
DIE ROLLE WIRD NACHGESCHLAGEN statt durchgereicht: `zuteilen`
bekommt nur eine Nummer, und eine zusaetzliche Angabe im Aufruf
waere die, die beim dritten Aufrufer fehlt. Eine Abfrage mehr
kostet nichts und kann nicht vergessen werden.
ES WIRD ABGELEHNT UND NICHT STILL HERAUSGEFILTERT: Wer sich
einträgt und sich danach nicht in der Liste findet, sucht den
Fehler bei sich. */
const verteiler = vonId
? db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(vonId)
: null;
if (verteiler && echt.includes(verteiler.id)
&& !entscheidetUeberAufgaben(verteiler)) {
return { fehler: "nicht_an_dich_selbst" };
}
/* DIE ART WIRD ABGELEITET, WO SIE SICH ABLEITEN LÄSST. Eine Person
kann kein Pool sein — "der erste, der Zeit hat" braucht mindestens
zwei, die es sehen. Wer trotzdem "pool" schickt, bekommt
"einzeln"; das ist, was er gemeint haben muss. */
let gesetzteArt = VERTEILARTEN.has(art) ? art : (echt.length > 1 ? "mehrere" : "einzeln");
if (echt.length === 1) gesetzteArt = "einzeln";
const nun = jetzt();
const alt = db().prepare(
"SELECT person_id, zustand FROM aufgaben_zuteilung WHERE aufgabe_id = ?").all(id);
const behalten = new Map(alt.map((z) => [z.person_id, z.zustand]));
/* WER SCHON GEANTWORTET HAT, BEHÄLT SEINEN STAND. Sonst setzte ein
zweites Speichern im Formular ein "angenommen" zurück auf "offen"
— und der Mensch müsste noch einmal zusagen, ohne zu wissen,
warum. */
const setz = db().prepare(`
INSERT INTO aufgaben_zuteilung (aufgabe_id, person_id, zustand, zugeteilt_am, zugeteilt_von)
VALUES (?,?,?,?,?)
ON CONFLICT (aufgabe_id, person_id) DO NOTHING`);
for (const pid of echt) setz.run(id, pid, "offen", nun, vonId ?? null);
/* WER NICHT MEHR DABEI IST, FLIEGT RAUS — aber nur, wenn er noch
nicht geantwortet hat. Jemandem eine angenommene Aufgabe
wegzunehmen, ohne dass er es erfährt, wäre die unangenehmste Art,
Arbeit zu verlieren. */
for (const [pid, zustand] of behalten) {
if (echt.includes(pid)) continue;
if (zustand !== "offen") continue;
db().prepare("DELETE FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.run(id, pid);
}
db().prepare("UPDATE aufgaben SET verteilart = ? WHERE id = ?").run(gesetzteArt, id);
/* `verantwortlich_id` mitführen — siehe Kopf. Bei "einzeln" ist sie
die Person, sonst bleibt sie leer, bis jemand übernimmt. */
if (gesetzteArt === "einzeln") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?").run(echt[0], id);
} else if (gesetzteArt === "mehrere") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = NULL WHERE id = ?").run(id);
}
return { gesetzt: echt, art: gesetzteArt };
}
/* =====================================================================
DIE WEGE
===================================================================== */
function holen(req, res) {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ fehler: "ungueltig" }); return null; }
/* `dauerhaft` GEHOERT DAZU (30.09.2026). Ohne dieses Feld war die
Sperre in `mein-stand` still wirkungslos: Sie fragte `a.dauerhaft`
und bekam `undefined`. Gefunden hat es die Pruefung -- im
Quelltext daneben sah alles richtig aus.
AUFGEZAEHLT UND NICHT `SELECT *`: Diese Zeile liest eine Aufgabe
fuer Rechteentscheidungen. Was sie nicht braucht, soll sie auch
nicht in der Hand haben. */
const a = db().prepare(
"SELECT id, titel, status, verteilart, dauerhaft FROM aufgaben WHERE id = ?").get(id);
if (!a) { res.status(404).json({ fehler: "gibt_es_nicht" }); return null; }
return a;
}
/** Eine erledigte oder abgebrochene Aufgabe hat keine offenen
* Bewerbungen mehr (01.10.2026).
*
* GEFUNDEN AN DEN ECHTEN DATEN: Zwei Bewerbungen standen auf
* `beworben` an Aufgaben, die laengst durch waren. Bei den
* Bewerbern stand weiter „wartet auf Antwort", und in der Liste der
* Leitung stand eine Entscheidung an, die es nicht mehr gibt.
*
* DAS MUSTER GIBT ES HIER SCHON, eine Funktion weiter oben:
* `uebernahmeAbschliessen` raeumt genau so auf, wenn jemand anderes
* eine Pool-Aufgabe bekommt. Derselbe Fall, ein anderer Ausloeser --
* deshalb dieselbe Behandlung und dasselbe Feld.
*
* KEIN `entschieden_von`: Niemand hat entschieden, die Frage hat
* sich erledigt. Dadurch faellt die Zeile auch aus der
* Absagen-Uebersicht heraus (die fragt `entschieden_von IS NOT
* NULL`) -- richtig, denn es ist keine Absage an diesen Menschen.
*
* @returns {number} wie viele weggefallen sind -- die Zahl braucht
* die Pruefung, und der Aufrufer soll nicht raten muessen.
*/
export function bewerbungenSchliessen(aufgabeId, grund) {
try {
const nun = jetzt();
const e = db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', entscheid_text = ?,
entschieden_am = ?, geantwortet_am = ?
WHERE aufgabe_id = ? AND zustand = 'beworben'`)
.run(grund, nun, nun, Number(aufgabeId));
return Number(e?.changes) || 0;
} catch (fehler) {
console.error("[zuteilung] Bewerbungen schliessen:", fehler?.message);
return 0;
}
}
/** Meine eigene Zuteilung zu dieser Aufgabe — oder nichts. */
function meine(aufgabeId, personId) {
return db().prepare(
"SELECT * FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.get(aufgabeId, personId) || null;
}
/* ---- Annehmen ------------------------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/annehmen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
if (z.zustand === "erledigt") return res.status(409).json({ fehler: "schon_erledigt" });
/* BEIM POOL IST „ANNEHMEN" DASSELBE WIE „UEBERNEHMEN" (22.09.2026).
Das ist beim Bauen aufgefallen, nicht beim Planen: Der Weg
`uebernehmen` wurde fuer Modis und die linke Hand geschlossen --
und `annehmen` stand daneben weit offen. Bei einer Pool-Aufgabe
heisst annehmen aber genau, sie den anderen wegzunehmen
(`uebernahmeAbschliessen` weiter unten in dieser Funktion). Ein
Verbot mit einer zweiten, offenen Tuer ist keins.
BEI „einzeln" UND „mehrere" BLEIBT ES, WIE ES WAR. Dort wurde
die Aufgabe jemandem ZUGETRAGEN -- und genau die darf er
annehmen. Filipes Satz nennt den Unterschied selbst: „keine
aufgaben selber uebernehmen DIE IHNEN NICHT ZUGETRAGEN SIND." */
if (a.verteilart === "pool" && !entscheidetUeberAufgaben(req.person)) {
return res.status(403).json({ fehler: "erst_bewerben" });
}
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'angenommen', grund = NULL, geantwortet_am = ?
WHERE id = ?`).run(jetzt(), z.id);
/* BEIM POOL GEWINNT DER ERSTE. Sobald einer zusagt, ist sie für
die anderen weg — das ist der Zweck der Sache. */
if (a.verteilart === "pool") uebernahmeAbschliessen(a.id, req.person.id);
protokolliere("aufgabe_angenommen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true, zustand: "angenommen" });
} catch (f) {
console.error("[zuteilung] annehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Ablehnen, mit Begründung --------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/ablehnen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
const grund = String(req.body?.grund ?? "").trim();
if (grund.length < 3) return res.status(400).json({ fehler: "grund_fehlt" });
if (grund.length > 500) return res.status(400).json({ fehler: "grund_zu_lang" });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', grund = ?, geantwortet_am = ?
WHERE id = ?`).run(grund, jetzt(), z.id);
/* HAT SIE NIEMAND MEHR? Dann muss das sichtbar sein. Die Aufgabe
bleibt stehen (sie ist ja nicht erledigt) — aber ohne
Verantwortliche, damit sie im Resümee der Leitung auffällt. */
if (a.verteilart === "einzeln") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = NULL WHERE id = ?").run(a.id);
}
protokolliere("aufgabe_abgelehnt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${grund}`.slice(0, 120),
});
res.json({ ok: true, zustand: "abgelehnt" });
} catch (f) {
console.error("[zuteilung] ablehnen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Beim Pool: Einer hat sie, alle anderen sind raus.
*
* ALS EIGENE FUNKTION, weil zwei Wege hierher führen (annehmen und
* übernehmen) und zwei Abschriften derselben Regel auseinanderlaufen. */
function uebernahmeAbschliessen(aufgabeId, personId) {
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', grund = 'Von jemand anderem übernommen',
geantwortet_am = ?
WHERE aufgabe_id = ? AND person_id <> ? AND zustand = 'offen'`)
.run(jetzt(), aufgabeId, personId);
/* DIE OFFENEN BEWERBUNGEN FALLEN AUCH WEG (22.09.2026).
Ohne diese Zeile blieb eine Bewerbung auf „beworben" stehen,
nachdem jemand anders die Aufgabe bekommen hat -- die Person
haette weiter gewartet, und in der Liste der Leitung staende eine
Entscheidung an, die es nicht mehr gibt. Gefunden hat es die
Pruefung.
EIGENE ANWEISUNG UND NICHT DIESELBE, und der Grund ist das Feld
`grund`: Dort stehen bei einer Bewerbung die WORTE DER PERSON
(„Ich habe Freitag Zeit."). Sie mit „Von jemand anderem
uebernommen" zu ueberschreiben hiesse, die Frage mit der Antwort
zu loeschen -- genau das, was die Trennung der zwei Felder
verhindern soll. Der Hinweis kommt deshalb in `entscheid_text`. */
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt',
entscheid_text = 'Von jemand anderem übernommen',
entschieden_am = ?, geantwortet_am = ?
WHERE aufgabe_id = ? AND person_id <> ? AND zustand = 'beworben'`)
.run(jetzt(), jetzt(), aufgabeId, personId);
db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?")
.run(personId, aufgabeId);
}
/* ---- Aus dem Pool übernehmen ---------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/uebernehmen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
if (a.verteilart !== "pool") return res.status(409).json({ fehler: "kein_pool" });
/* WER NICHT ENTSCHEIDET, NIMMT SICH AUCH NICHTS (22.09.2026).
Filipe: „ueberhaupt ueberall sollen die keine aufgaben selber
uebernehmen die ihnen nicht zugetragen sind."
Fuer Modis und die linke Hand fuehrt der Weg jetzt ueber eine
Bewerbung -- der Fehlercode sagt das, statt nur nein. */
if (!entscheidetUeberAufgaben(req.person)) {
return res.status(403).json({ fehler: "erst_bewerben" });
}
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
/* HAT SCHON JEMAND? Diese Frage muss VOR dem Schreiben stehen,
sonst nehmen zwei Leute im selben Moment dieselbe Aufgabe und
beide sehen "hat geklappt". */
const schon = db().prepare(`SELECT p.name FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
WHERE z.aufgabe_id = ? AND z.person_id <> ?
AND z.zustand IN ('angenommen','arbeit','erledigt')`).get(a.id, req.person.id);
if (schon) return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'angenommen', grund = NULL, geantwortet_am = ?
WHERE id = ?`).run(jetzt(), z.id);
uebernahmeAbschliessen(a.id, req.person.id);
protokolliere("aufgabe_uebernommen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true, zustand: "angenommen" });
} catch (f) {
console.error("[zuteilung] uebernehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
SICH BEWERBEN — und wer darueber entscheidet (22.09.2026)
=====================================================================
Filipe: „ich will dass die sich fuer aufgaben bewerben koennen aber
die rechte hand oder dogfather muessen annehmen oder ablehnen koennen
und das mit einem kommentar als moeglichkeit sogar noch zum
hinzufuegen."
DREI ENTSCHEIDUNGEN, die nicht auf der Hand lagen:
(1) DER KOMMENTAR IST FREIWILLIG, die Begruendung beim ABLEHNEN einer
zugeteilten Aufgabe bleibt Pflicht. Das ist kein Widerspruch:
Dort sagt jemand ab, der gefragt wurde -- der Fragende muss
wissen, warum, sonst fragt er nach. Hier entscheidet die
Leitung ueber eine Bitte; ein „ja" braucht keine Begruendung.
Filipes Wort ist „als moeglichkeit", nicht „als pflicht".
(2) DER TEXT DER BEWERBUNG UND DER KOMMENTAR DER LEITUNG STEHEN IN
ZWEI FELDERN. `grund` traegt die Worte der Person selbst, der
Kommentar kommt in `entscheid_text`. In dasselbe Feld waere
bequemer und wuerde die Frage mit der Antwort ueberschreiben --
danach weiss niemand mehr, worum jemand gebeten hat.
(3) WER SICH BEWIRBT, IST NICHT ZUGETEILT. Es entsteht eine Zeile
mit `zustand = 'beworben'` und ohne `zugeteilt_von` -- sonst
staende in der Liste, jemand haette ihm die Aufgabe gegeben.
===================================================================== */
const BEWERBUNG_TEXT_MAX = 500;
/* ---- Sich bewerben --------------------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerben", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
/* WER SELBST ENTSCHEIDET, BEWIRBT SICH NICHT. Er teilt sich die
Aufgabe zu -- eine Bewerbung an sich selbst waere ein Umweg mit
zwei Klicks und demselben Ergebnis. */
if (entscheidetUeberAufgaben(req.person)) {
return res.status(409).json({ fehler: "du_entscheidest_selbst" });
}
if (a.status === "erledigt" || a.status === "abgebrochen") {
return res.status(409).json({ fehler: "schon_erledigt" });
}
const z = meine(a.id, req.person.id);
if (z && z.zustand === "beworben") {
return res.status(409).json({ fehler: "schon_beworben" });
}
/* WER SIE SCHON HAT, BEWIRBT SICH NICHT -- mit einer Ausnahme,
und die ist der Kern der Sache:
einzeln/mehrere, Zustand „offen" -> sie wurde IHM zugetragen.
Er nimmt sie an oder lehnt
sie ab. Keine Bewerbung.
pool, Zustand „offen" -> sie wurde ANGEBOTEN, nicht
zugetragen. Frueher hiess
die Antwort darauf „ich
nehme sie mir"; seit heute
heisst sie „ich bewerbe
mich".
Ohne diese Unterscheidung haette der erlaubte Weg fuer genau die
Aufgaben nicht funktioniert, fuer die er gedacht ist. Gefunden
hat es die Pruefung, nicht das Nachdenken. */
const angeboten = z && z.zustand === "offen" && a.verteilart === "pool";
if (z && !angeboten && z.zustand !== "abgelehnt") {
return res.status(409).json({ fehler: "schon_zugeteilt" });
}
/* HAT SIE SCHON JEMAND? Dann ist eine Bewerbung eine Enttaeuschung
mit Anlauf. Dieselbe Frage wie beim Uebernehmen, damit beide
Wege dasselbe sagen. */
const schon = db().prepare(`SELECT p.name FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
WHERE z.aufgabe_id = ? AND z.person_id <> ?
AND z.zustand IN ('angenommen','arbeit','erledigt')`).get(a.id, req.person.id);
if (schon && a.verteilart === "pool") {
return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name });
}
const text = String(req.body?.text ?? "").trim().slice(0, BEWERBUNG_TEXT_MAX);
const nun = jetzt();
if (z) {
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'beworben', grund = ?, geantwortet_am = ?,
entscheid_text = NULL, entschieden_von = NULL, entschieden_am = NULL
WHERE id = ?`).run(text || null, nun, z.id);
} else {
/* `zugeteilt_von` bleibt leer: Niemand hat sie ihm gegeben. */
db().prepare(`INSERT INTO aufgaben_zuteilung
(aufgabe_id, person_id, zustand, grund, zugeteilt_am, geantwortet_am)
VALUES (?,?,'beworben',?,?,?)`)
.run(a.id, req.person.id, text || null, nun, nun);
}
/* ==== UND JETZT ERFAEHRT ES AUCH JEMAND (23.09.2026) =========
Bis heute endete eine Bewerbung hier: gespeichert, protokolliert
-- und still. DogFather erfuhr davon nur, wenn er von sich aus
das Brett aufmachte. Wer sich bewirbt, wartet aber; Warten ohne
Rueckmeldung fuehlt sich nach zwei Tagen an wie "interessiert
keinen", und genau das soll eine Bewerbung verhindern.
NICHT ABWARTEN (`void`): Ob die Meldung durchgeht, haengt am
Push-Dienst, an der Ruhezeit und an den Einstellungen des
Empfaengers. Nichts davon darf entscheiden, ob die Bewerbung
selbst gespeichert ist -- die ist es laengst. */
void meldeBewerbung({
bewerber: { id: req.person.id, name: req.person.name },
titel: a.titel,
wort: text,
ziel: `/workspace/aufgaben.html?aufgabe=${a.id}`,
});
protokolliere("aufgabe_beworben", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true, zustand: "beworben" });
} catch (f) {
console.error("[zuteilung] bewerben:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Eine Bewerbung zuruecknehmen ------------------------------------ */
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerbung-zurueck", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z || z.zustand !== "beworben") {
return res.status(409).json({ fehler: "keine_bewerbung" });
}
/* GANZ WEG, nicht „zurueckgezogen". Wer es sich anders ueberlegt,
hat nichts getan, was jemand nachlesen muesste -- und eine Zeile,
die nur noch sagt „hier stand mal eine Bewerbung", waere in der
Liste der Leitung Laerm. */
db().prepare("DELETE FROM aufgaben_zuteilung WHERE id = ?").run(z.id);
protokolliere("bewerbung_zurueckgezogen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true });
} catch (f) {
console.error("[zuteilung] bewerbung-zurueck:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Ueber eine Bewerbung entscheiden -------------------------------- */
for (const [weg, entscheidung] of [["annehmen", "ja"], ["ablehnen", "nein"]]) {
zuteilungRouter.post(`/workspace/api/aufgaben/:id/bewerbung/${weg}`, (req, res) => {
try {
const a = holen(req, res); if (!a) return;
/* NUR DIE RECHTE HAND UND DOGFATHER. Die linke Hand darf
verteilen, aber nicht ueber Bewerbungen entscheiden -- sie
bewirbt sich ja selbst. */
if (!entscheidetUeberAufgaben(req.person)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
const wer = Number(req.body?.person_id);
if (!Number.isInteger(wer)) return res.status(400).json({ fehler: "ungueltig" });
const z = db().prepare(`SELECT * FROM aufgaben_zuteilung
WHERE aufgabe_id = ? AND person_id = ?`).get(a.id, wer);
if (!z || z.zustand !== "beworben") {
return res.status(409).json({ fehler: "keine_bewerbung" });
}
const kommentar = String(req.body?.kommentar ?? "").trim()
.slice(0, BEWERBUNG_TEXT_MAX) || null;
const nun = jetzt();
if (entscheidung === "ja") {
/* HAT INZWISCHEN JEMAND ANDERES? Die Frage muss VOR dem
Schreiben stehen -- sonst sagen zwei Zusagen im selben
Moment beide „hat geklappt". */
const schon = db().prepare(`SELECT p.name FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
WHERE z.aufgabe_id = ? AND z.person_id <> ?
AND z.zustand IN ('angenommen','arbeit','erledigt')`).get(a.id, wer);
if (schon && a.verteilart === "pool") {
return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name });
}
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'angenommen', geantwortet_am = ?,
entscheid_text = ?, entschieden_von = ?, entschieden_am = ?
WHERE id = ?`).run(nun, kommentar, req.person.id, nun, z.id);
if (a.verteilart === "pool") uebernahmeAbschliessen(a.id, wer);
else {
db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?")
.run(wer, a.id);
}
} else {
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', geantwortet_am = ?,
entscheid_text = ?, entschieden_von = ?, entschieden_am = ?
WHERE id = ?`).run(nun, kommentar, req.person.id, nun, z.id);
}
/* DIE ANTWORT IST DER WICHTIGERE TEIL. Eine Absage, die man
drei Tage spaeter zufaellig entdeckt, ist schlimmer als eine
sofortige -- und die NOTIZ geht mit, denn genau dafuer hat
Filipe sie verlangt ("mit einem text als notiz"). Sie erst zu
verlangen und dann an der Stelle zu verschweigen, an der man
sie liest, waere die halbe Funktion. */
void meldeBewerbungsantwort({
anWen: wer,
titel: a.titel,
ja: entscheidung === "ja",
notiz: kommentar,
ziel: `/workspace/aufgaben.html?aufgabe=${a.id}`,
});
protokolliere(entscheidung === "ja" ? "bewerbung_angenommen" : "bewerbung_abgelehnt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} fuer ${wer}`.slice(0, 120),
});
res.json({ ok: true, zustand: entscheidung === "ja" ? "angenommen" : "abgelehnt" });
} catch (f) {
console.error(`[zuteilung] bewerbung/${weg}:`, f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
}
/* ---- Meinen eigenen Stand setzen ------------------------------------ */
zuteilungRouter.post("/workspace/api/aufgaben/:id/mein-stand", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
const ziel = String(req.body?.zustand ?? "").trim();
/* NUR DIE DREI, DIE MAN SELBST SETZEN DARF. "offen" zurückzudrehen
hieße, die eigene Zusage zu löschen; "abgelehnt" hat einen
eigenen Weg, weil es eine Begründung braucht. */
if (!["angenommen", "arbeit", "erledigt"].includes(ziel)) {
return res.status(400).json({ fehler: "ungueltiger_zustand" });
}
if (z.zustand === "abgelehnt") return res.status(409).json({ fehler: "abgelehnt" });
/* ==== EINE DAUERHAFTE AUFGABE WIRD NICHT FERTIG (30.09.2026) ====
VanVan im Support: „… dass die Aufgabe dauerhaft sein soll und
somit nicht vom Modi in den Status erledigt gesetzt werden
kann."
Sie ist keine Aufgabe, die man abarbeitet, sondern eine, die
man TUT. „Neue begruessen" ist nicht fertig, wenn man es einmal
gemacht hat.
„ICH FANGE AN" BLEIBT ERLAUBT. Auch eine stehende Aufgabe hat
einen Anfang, und der Unterschied zwischen „angenommen" und „in
Arbeit" sagt etwas. Gesperrt ist nur das Abhaken.
DIE SPERRE STEHT HIER UND NICHT NUR IM BROWSER. Ein fehlender
Knopf ist eine Bitte; abgelehnt wird am Server -- wer die
Schnittstelle direkt anspricht, kaeme sonst durch.
BEENDET WIRD SIE VON DER LEITUNG, ueber den Status der Aufgabe
selbst. Eine Pflicht, die niemand mehr beenden kann, waere eine
Falle statt einer Regel. */
if (ziel === "erledigt" && a.dauerhaft) {
return res.status(409).json({ fehler: "dauerhafte_aufgabe" });
}
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = ?, erledigt_am = ?
WHERE id = ?`).run(ziel, ziel === "erledigt" ? jetzt() : null, z.id);
protokolliere("aufgabe_stand", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${ziel}`.slice(0, 120),
});
res.json({ ok: true, zustand: ziel });
} catch (f) {
console.error("[zuteilung] mein-stand:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Bewerten (Abschnitt 5) ----------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerten", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
if (!darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "darf_nicht_bewerten" });
}
const personId = Number(req.body?.person_id);
const bewertung = String(req.body?.bewertung ?? "").trim();
const text = String(req.body?.text ?? "").trim();
if (!BEWERTUNGEN.has(bewertung)) return res.status(400).json({ fehler: "ungueltige_bewertung" });
/* BEI "VERBESSERUNGSMÖGLICHKEITEN" IST DER TEXT PFLICHT. Filipe:
"soll eine entsprechende Kachel bzw. ein Eingabebereich
erscheinen. Dogfather und die rechte Hand sollen dort einen
individuellen Text hinterlegen." Ein leeres "kann besser" sagt
dem, der es liest, nichts — außer dass jemand unzufrieden war. */
if (bewertung === "verbessern" && text.length < 3) {
return res.status(400).json({ fehler: "text_fehlt" });
}
if (text.length > 2000) return res.status(400).json({ fehler: "text_zu_lang" });
const z = db().prepare(
"SELECT * FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.get(a.id, personId);
if (!z) return res.status(404).json({ fehler: "nicht_zugeteilt" });
/* ERST FERTIG, DANN BEWERTEN. Eine Rückmeldung auf etwas, das noch
läuft, ist keine Bewertung, sondern eine Einmischung. */
if (z.zustand !== "erledigt") return res.status(409).json({ fehler: "noch_nicht_erledigt" });
db().prepare(`UPDATE aufgaben_zuteilung
SET bewertung = ?, bewertung_text = ?, bewertet_von = ?, bewertet_am = ?
WHERE id = ?`).run(bewertung, text || null, req.person.id, jetzt(), z.id);
protokolliere("aufgabe_bewertet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} -> #${personId} ${bewertung}`.slice(0, 120),
});
res.json({ ok: true, bewertung, text: text || null });
} catch (f) {
console.error("[zuteilung] bewerten:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Das persönliche Resümee (Abschnitt 1 und 4) -------------------- */
/** Zählt für eine Person, wie ihre Aufgaben stehen.
*
* ABGELEITET AUS DEN ZUSTÄNDEN, nicht aus einer zweiten Tabelle: Eine
* mitgeführte Zahl ist eine Zahl von gestern, sobald jemand etwas
* ändert und die Stelle vergisst. */
/** Der Zustand einer Aufgabe, die jemandem auf dem ALTEN Weg gehoert.
*
* Die Aufgabentabelle kennt offen/arbeit/review/erledigt/abgebrochen,
* die Zuteilung kennt offen/angenommen/arbeit/erledigt/abgelehnt.
* "review" heisst: liegt zur Freigabe, ist also noch nicht vom Tisch --
* deshalb zaehlt es wie "arbeit". "abgebrochen" ist weder dran noch
* erledigt und zaehlt gar nicht mit; es erscheint auch im Brett nicht
* in den Spalten OFFEN und IN ARBEIT.
*
* Die Zuordnung steht hier an EINER Stelle. Wer sie an zwei Stellen
* schreibt, hat beim naechsten Statuswert eine, die niemand nachzieht. */
const STATUS_ALS_ZUSTAND = {
offen: "offen", arbeit: "arbeit", review: "arbeit", erledigt: "erledigt",
};
/** Der Status der Aufgabe zieht die Zuteilungen mit. (02.10.2026)
*
* ==== WARUM ES DAS GIBT =============================================
*
* VanVan im Support: „Wenn man auf ich fange an drückt steht dort in
* Bearbeitung und wenn man auf fertig drückt dann wird es zu erledigt.
* Die Aufgabe bleibt aber im status offen stehen. Die beiden Buttons
* können entfernt werden, weil es darüber ja den button starten gibt,
* der auch korrekt funktioniert."
*
* Sie hat zwei Zustandsmaschinen gefunden, die nebeneinander liefen
* und sich nicht kannten:
*
* aufgaben.status offen · arbeit · review · erledigt
* aufgaben_zuteilung.zustand angenommen · arbeit · erledigt
*
* Die zwei Knoepfe setzten den zweiten, der „starten"-Knopf den
* ersten. Auf der Karte stand „in Bearbeitung", in der Liste „offen",
* und beides war richtig -- was es noch schlimmer macht.
*
* DIE KNOEPFE SIND WEG, WIE SIE ES WOLLTE. Aber sie einfach zu
* loeschen waere eine stille Verschlechterung gewesen: Die ZAEHLER
* einer Person („offen / in Arbeit / erledigt" im Resuemee) lesen den
* Zustand der ZUTEILUNG, nicht den der Aufgabe. Ohne die Knoepfe
* waere jede Zuteilung fuer immer auf „angenommen" stehen geblieben,
* und die Zahlen haetten aufgehoert, die Wirklichkeit zu zeigen --
* ohne dass irgendwo etwas rot wird.
*
* Jetzt gibt es EINE Wahrheit: den Status der Aufgabe. Die Zuteilung
* folgt ihm.
*
* ABGELEITET, NICHT ZWEIMAL GESCHRIEBEN: Die Zuordnung oben wird
* benutzt, mit genau EINER Abweichung, und die steht hier --
* `offen` bedeutet in der Zuteilung „noch nicht zugesagt". Wer schon
* zugesagt hat, faellt nicht dorthin zurueck, nur weil die Aufgabe
* wieder auf offen gesetzt wird; er ist dann „angenommen".
*
* WESSEN ZUSTAND SICH AENDERT: nur der von Leuten, die die Aufgabe
* WIRKLICH haben (angenommen/arbeit/erledigt). Eine Bewerbung
* („beworben") ist keine Zusage, und ein „abgelehnt" ist eine
* Entscheidung -- beide darf ein Statuswechsel nicht ueberschreiben.
*/
export function zuteilungenNachStatus(aufgabeId, status) {
const abgeleitet = STATUS_ALS_ZUSTAND[status];
/* „abgebrochen" steht nicht in der Zuordnung -- dort bleibt alles,
wie es war. Wer wieder aufnimmt, findet seinen Stand vor. */
if (!abgeleitet) return 0;
const ziel = abgeleitet === "offen" ? "angenommen" : abgeleitet;
const e = db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = ?, erledigt_am = ?
WHERE aufgabe_id = ?
AND zustand IN ('angenommen','arbeit','erledigt')
AND zustand <> ?`)
.run(ziel, ziel === "erledigt" ? jetzt() : null, Number(aufgabeId), ziel);
return e.changes || 0;
}
export function resuemeeFuer(personId) {
/* ZWEI QUELLEN, EIN ERGEBNIS (22.09.2026).
==========================================================
Filipe: "die aufgaben die man unten sieht soll man auch oben
sehen."
Gemessen an den echten Daten: aufgaben_zuteilung hatte NULL
Zeilen, waehrend im Brett zwei Aufgaben standen (OFFEN 1,
IN ARBEIT 1). Jede Person las oben "nichts zugeteilt" -- weil
diese Zaehlung nur die neue Tabelle kannte. Die Aufgaben hingen
am aelteren Feld `aufgaben.verantwortlich_id`, und das ist genau
das Feld, nach dem das BRETT gruppiert.
Eine Uebersicht, die eine andere Quelle liest als die Liste
darunter, widerspricht ihr frueher oder spaeter. Deshalb zaehlen
hier BEIDE Wege -- und keiner doppelt: Gibt es zu einer Aufgabe
eine Zuteilungszeile fuer diese Person, gewinnt die Zuteilung
(sie hat den genaueren Zustand: angenommen, abgelehnt, bewertet).
Nur wo keine Zeile existiert, zaehlt das alte Feld. */
const zeilen = db().prepare(`
SELECT z.zustand, z.bewertung, a.frist, a.status AS aufgabe_status
FROM aufgaben_zuteilung z
JOIN aufgaben a ON a.id = z.aufgabe_id
WHERE z.person_id = ?`).all(Number(personId));
const alte = db().prepare(`
SELECT a.status, a.frist
FROM aufgaben a
WHERE a.verantwortlich_id = ?
AND NOT EXISTS (SELECT 1 FROM aufgaben_zuteilung z
WHERE z.aufgabe_id = a.id AND z.person_id = a.verantwortlich_id)
`).all(Number(personId));
for (const a of alte) {
const zustand = STATUS_ALS_ZUSTAND[a.status];
if (!zustand) continue; /* abgebrochen: zaehlt nirgends */
zeilen.push({ zustand, bewertung: null, frist: a.frist, aufgabe_status: a.status });
}
/* ORTSZEIT (01.10.2026): Mit dem UTC-Tag galt eine Aufgabe, deren
Frist gestern ablief, nachts zwischen 00:00 und 02:00 noch als
puenktlich -- und die Zahl „ueberfaellig" war zwei Stunden lang
zu niedrig. */
const heute = heuteLokal();
const zaehl = { offen: 0, angenommen: 0, arbeit: 0, erledigt: 0, abgelehnt: 0 };
let ueberfaellig = 0, gelobt = 0, zuVerbessern = 0;
for (const z of zeilen) {
if (zaehl[z.zustand] !== undefined) zaehl[z.zustand]++;
const laeuft = z.zustand !== "erledigt" && z.zustand !== "abgelehnt";
if (laeuft && z.frist && z.frist < heute) ueberfaellig++;
if (z.bewertung === "gut") gelobt++;
if (z.bewertung === "verbessern" || z.bewertung === "nicht_gut") zuVerbessern++;
}
return {
gesamt: zeilen.length, ...zaehl, ueberfaellig, gelobt, zu_verbessern: zuVerbessern,
/* Was gerade zu tun ist — die eine Zahl, die ein Mensch morgens
wissen will. */
dran: zaehl.offen + zaehl.angenommen + zaehl.arbeit,
};
}
zuteilungRouter.get("/workspace/api/aufgaben/resuemee", (req, res) => {
try {
/* WER DARF WESSEN RESÜMEE SEHEN? Sein eigenes jeder. Fremde nur,
wer Aufgaben verteilt — das ist genau die Gruppe, die im Auftrag
"eine vollständige Gesamtübersicht" bekommen soll. */
const wunsch = Number(req.query?.person);
const fremd = Number.isInteger(wunsch) && wunsch !== req.person.id;
if (fremd && !darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
if (!fremd) return res.json({ person_id: req.person.id, ...resuemeeFuer(req.person.id) });
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(wunsch);
if (!p) return res.status(404).json({ fehler: "gibt_es_nicht" });
res.json({ person_id: p.id, name: p.name, rolle: p.rolle, ...resuemeeFuer(p.id) });
} catch (f) {
console.error("[zuteilung] resuemee:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Das Resümee ALLER — für DogFather, rechte und linke Hand.
*
* Abschnitt 1: "Auf einen Blick muss erkennbar sein, wer welche
* Aufgabe hat, was erledigt wurde, was noch offen ist und wo
* Handlungsbedarf besteht." */
zuteilungRouter.get("/workspace/api/aufgaben/team-resuemee", (req, res) => {
try {
if (!darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
/* WER GEHÖRT IN DIE ÜBERSICHT: alle, die Aufgaben bekommen können.
Abgeleitet aus der Rollenliste dieses Hauses statt aus einer
eigenen Aufzählung — wer morgen eine Rolle hinzufügt, muss hier
nichts nachziehen. */
/* WER IN DER LISTE STEHT -- ABGELEITET, NICHT AUFGEZAEHLT.
==========================================================
Die Rollen des Hauses stehen immer da, auch mit null Aufgaben:
Wer frei ist, ist die haeufigste Frage beim Verteilen.
Dazu kommt JEDE aktive Person, der im Brett eine Aufgabe
gehoert. Ohne diesen zweiten Teil koennte im Brett eine Aufgabe
stehen, deren Mensch oben fehlt -- genau der Widerspruch, den
Filipe am 22.09. gemeldet hat, nur an anderer Stelle. So kann
das nicht wieder entstehen: Die Liste folgt den Aufgaben. */
const rollen = [...TEAM_DOGI_ROLLEN];
const platz = rollen.map(() => "?").join(",");
const leute = db().prepare(`
SELECT id, name, rolle FROM personen
WHERE aktiv = 1
AND (rolle IN (${platz})
OR id IN (SELECT verantwortlich_id FROM aufgaben
WHERE verantwortlich_id IS NOT NULL)
OR id IN (SELECT person_id FROM aufgaben_zuteilung))
AND rolle NOT IN ('creator','scout','manager','spicy','gast')
ORDER BY CASE rolle WHEN 'admin' THEN 0 WHEN 'hand' THEN 1
WHEN 'linke' THEN 2 ELSE 3 END,
name COLLATE NOCASE`).all(...rollen);
res.json({
leute: leute.map((p) => ({ ...p, ...resuemeeFuer(p.id) })),
darf_bewerten: true,
ist_dogfather: istDogFather(req.person),
ist_hand: istHand(req.person),
});
} catch (f) {
console.error("[zuteilung] team-resuemee:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});