Files
dogfather-universe/server-internal/routes/webdesign-aufgaben.js
T
DogFatherGitandClaude Opus 5 98fc04c666 Verwaltung: Projektstand aendern, Wuensche beziffern, im Projekt antworten
Drei Server-Funktionen existierten seit Tagen und hatten KEINE Oberflaeche.
Erst im Vergleich "was kann der Server" gegen "was ruft die Seite auf"
ist es aufgefallen:

  POST /admin/projekte/:id           projektAendern
  POST /admin/aenderungen/:id/beziffern
  POST /admin/projekt-nachricht

Jede davon schliesst einen Kreis, der bisher offen war.

1. DIE SCHMERZHAFTESTE LUECKE: DER STAND

Der Kunde sieht in seinem Portal GANZ OBEN die Phasenleiste und den
"naechsten Schritt". Beides liess sich nirgends aendern. Ein Projekt blieb
also fuer immer im Briefing stehen, egal wie weit es wirklich war -- und
der prominenteste Text im ganzen Kundenportal war dauerhaft leer.

Jetzt: sieben Phasen als Knoepfe (plus Pausiert/Abgebrochen daneben, denn
das sind keine Phasen, sondern Zustaende), "wer ist am Zug", naechster
Schritt, Richttermin, Zahlungsstand, Portfolio-Freigabe.

Phase und "wer ist am Zug" speichern SOFORT ohne Speichern-Knopf: Es ist
ein Klick auf genau einen Wert, ein zweiter Klick waere Zeremonie. Die
Textfelder haben einen Knopf, weil man beim Tippen zwischendurch nicht
speichern will.

2. AENDERUNGSWUENSCHE

Der Kunde konnte Ideen einreichen, seit es das Portal gibt. Beziffern ging
serverseitig auch -- nur gab es keine Oberflaeche. Der Kreis war offen: Er
schickt etwas los und hoert nie wieder davon.

Jetzt stehen alle Wuensche im Projekt, offene mit Feldern fuer Preis,
Dauer und einer kurzen Erklaerung. Ohne Preis geht nichts raus (geprueft)
-- ohne Preis kann der Kunde nicht entscheiden, und ein "Angebot" ohne
Zahl ist keins.

3. NACHRICHTEN ZUM PROJEKT

Getrennt vom allgemeinen Postfach, weil sie zum Projekt gehoeren und im
Portal auch dort erscheinen. Sie hier nicht zu haben hiess: Der Kunde
schreibt im Projekt, und ich kann ihm nur woanders antworten.

AUFBAU

Neuer Endpunkt /admin/projekte/:id/alles liefert Projekt, Aufgaben,
Fortschritt, Aenderungswuensche und Nachrichten in EINER Antwort. Fuenf
Anfragen hintereinander wuerden die Ansicht sichtbar ruckelnd aufbauen.

Dieselbe Aufteilung wie in der Anfrageansicht -- links die Arbeit
(Aufgaben), rechts das Steuern. Wer zwischen beiden Ansichten wechselt,
muss nicht umdenken.

GEPRUEFT: 69 Pruefungen, Computer und Handy, alle bestanden.

Die neuen Pruefungen schauen nicht darauf, ob sich ein Knopf einfaerbt --
das beweist nichts. Sie schneiden mit, was die Seite TATSAECHLICH an den
Server schickt: {"status":"entwicklung"}, {"restBezahlt":true},
{"preisEuro":250,"dauer":"3 Tage"}. Und sie pruefen den Fall, in dem
nichts rausgehen darf: Ohne Preis bleibt die Mitschrift leer.

Ein Fehler im Test selbst behoben: Die Nachrichtenzaehlung war
document-weit und zaehlte die Nachrichten der geschlossenen Projektansicht
mit -- geschlossen heisst nicht aus dem Dokument entfernt. Jetzt auf den
Postfach-Verlauf eingegrenzt.

Versionsstempel und Cache-Name auf v6.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 12:08:57 +02:00

403 lines
16 KiB
JavaScript

/* =====================================================================
routes/webdesign-aufgaben.js — "Was ist schon gemacht von dem, was im
Plan war?"
Wunsch vom 22.08.2026. Bis hierher sah der Kunde nur die Phase
("Gestaltung · 3/7") — also eine Zahl ohne Inhalt. Die häufigste
Rückfrage in jedem Projekt ("wie weit seid ihr denn?") beantwortet
eine Phasenanzeige nicht.
Zwei Regeln durchziehen diese Datei:
1. Der Kunde sieht NUR seine eigenen Punkte. Jede Abfrage im
Kundenteil filtert auf kunde_id aus der SITZUNG — nie auf eine ID
aus der Anfrage. Eine Projekt-ID im Pfad ist eine Behauptung des
Aufrufers, die Sitzung ist ein Beweis.
2. Der Kunde darf abhaken, was IHM zugewiesen ist ("Texte geliefert"),
und sonst nichts. Das ist kein Misstrauen, sondern Klarheit: Wenn
beide Seiten alles abhaken könnten, wäre am Ende unklar, wer was
bestätigt hat — und genau diese Klarheit ist der Zweck der Liste.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
import { brauchtKunde } from "./webdesign-portal.js";
import { jetzt, neueId, text, verlauf } from "../lib/webdesign-helfer.js";
export const KATEGORIEN = ["start", "gestaltung", "umsetzung", "abschluss"];
export const AUFGABE_STATUS = ["offen", "in_arbeit", "erledigt", "entfaellt"];
const WER_DRAN = ["dogfather", "kunde"];
/* Gleiche Zugangsprüfung wie in webdesign-kunden.js — Ausweis der
Zugangswand ODER Team-Anmeldung mit Einzelrecht. Begründung dort. */
async function darf(req, recht) {
const ausweis = ausweisPruefen(req);
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
const sitzung = await requireSession(req);
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
if (sitzung.isOwner) return { erlaubt: true, sitzung };
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
}
function werIst(p) {
return p.sitzung?.actor ? "team:" + p.sitzung.actor : "team";
}
/* ---------------------------------------------------------------------
ZUSAMMENFASSUNG — die Zahlen, die oben über der Liste stehen
Absichtlich hier und nicht im Browser gerechnet: Verwaltung und Portal
brauchen dieselben Zahlen. Zweimal gerechnet heißt irgendwann
zweierlei Ergebnisse, und dann diskutiert man über Prozentwerte statt
über das Projekt.
"entfaellt" zählt NICHT in den Nenner. Ein Punkt, der wegfällt, darf
den Fortschritt weder bremsen noch künstlich beschleunigen.
--------------------------------------------------------------------- */
export function fortschritt(projektId) {
const zeilen = db
.prepare(`SELECT kategorie, status, wer_dran FROM wd_aufgaben WHERE projekt_id = ?`)
.all(projektId);
const gesamt = zeilen.filter((z) => z.status !== "entfaellt");
const erledigt = gesamt.filter((z) => z.status === "erledigt");
const jeKategorie = {};
for (const k of KATEGORIEN) {
const inK = gesamt.filter((z) => z.kategorie === k);
jeKategorie[k] = {
gesamt: inK.length,
erledigt: inK.filter((z) => z.status === "erledigt").length,
inArbeit: inK.filter((z) => z.status === "in_arbeit").length,
};
}
return {
gesamt: gesamt.length,
erledigt: erledigt.length,
inArbeit: gesamt.filter((z) => z.status === "in_arbeit").length,
/* Die wichtigste Zahl im Portal: offene Punkte, die auf den KUNDEN
warten. Steht sie auf 0, kann er beruhigt sein — steht sie höher,
weiß er sofort, dass es an ihm liegt. */
beimKunden: gesamt.filter((z) => z.wer_dran === "kunde" && z.status !== "erledigt").length,
prozent: gesamt.length ? Math.round((erledigt.length / gesamt.length) * 100) : 0,
jeKategorie,
};
}
/* ---------------------------------------------------------------------
KUNDENSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/portal/projekt/:id/aufgaben */
export function portalAufgaben(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
/* Das UND ist die ganze Absicherung. Ohne kunde_id in dieser Abfrage
könnte jeder angemeldete Kunde die Aufgabenliste jedes anderen
Projekts lesen, indem er die ID im Pfad austauscht. */
const projekt = db
.prepare(`SELECT id, nummer, titel, paket, status FROM wd_projekte WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const aufgaben = db
.prepare(
`SELECT id, titel, beschreibung, kategorie, status, wer_dran,
nicht_enthalten, hinweis, reihenfolge, erledigt_am
FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge, erstellt_am`
)
.all(projekt.id);
return json(res, { ok: true, projekt, aufgaben, fortschritt: fortschritt(projekt.id) });
}
/* POST /webdesign/portal/aufgabe/:id/erledigt
Der Kunde meldet einen eigenen Punkt als erledigt ("Texte geliefert").
Drei Bedingungen, alle notwendig: sein Projekt, wer_dran = kunde,
nicht als "nicht enthalten" markiert. */
export function portalAufgabeErledigt(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const aufgabe = db
.prepare(`SELECT * FROM wd_aufgaben WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
if (aufgabe.wer_dran !== "kunde") {
return json(
res,
{ ok: false, error: "Diesen Punkt erledigt Dogfather — du musst hier nichts tun." },
403
);
}
if (aufgabe.nicht_enthalten) {
return json(res, { ok: false, error: "Dieser Punkt gehört nicht zum Umfang." }, 403);
}
/* Der Kunde darf umschalten, nicht nur setzen. Ein Fehlklick soll ihn
nicht zwingen, deswegen zu schreiben. */
const neu = aufgabe.status === "erledigt" ? "offen" : "erledigt";
db.prepare(
`UPDATE wd_aufgaben SET status = ?, erledigt_am = ?, aktualisiert_am = ? WHERE id = ?`
).run(neu, neu === "erledigt" ? jetzt() : null, jetzt(), aufgabe.id);
verlauf("aufgabe", aufgabe.id, "kunde_" + neu, "kunde:" + kunde.id, aufgabe.titel);
return json(res, { ok: true, status: neu, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* ---------------------------------------------------------------------
VERWALTUNGSSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/admin/projekte/:id/aufgaben */
export async function adminAufgaben(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projektId = String(req.params.id || "");
const projekt = db
.prepare(
`SELECT p.id, p.nummer, p.titel, p.paket, p.status, p.kunde_id, k.name AS kunde_name
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?`
)
.get(projektId);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const aufgaben = db
.prepare(`SELECT * FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge, erstellt_am`)
.all(projektId);
return json(res, { ok: true, projekt, aufgaben, fortschritt: fortschritt(projektId) });
}
/* GET /webdesign/admin/projekte/:id/alles — das ganze Projekt
Warum ein zusätzlicher Endpunkt neben adminAufgaben: Die Verwaltung
konnte ein Projekt bisher nur ANLEGEN. Ändern, Änderungswünsche
beziffern und im Projekt antworten ging serverseitig längst — es gab
nur keine Oberfläche dafür.
Das war die schmerzhafteste Lücke im ganzen Bereich: Der Kunde sieht
in seinem Portal ganz oben die Phase und den "nächsten Schritt". Beide
liessen sich nirgends ändern. Ein Projekt blieb also für immer im
Briefing stehen, egal wie weit es wirklich war.
Alles in EINER Antwort statt in fünf: Beim Öffnen eines Projekts
braucht man ohnehin alles, und fünf Anfragen hintereinander bauen die
Ansicht sichtbar ruckelnd auf. */
export async function adminProjektAlles(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = String(req.params.id || "");
const projekt = db
.prepare(
`SELECT p.*, k.name AS kunde_name, k.firma AS kunde_firma, k.email AS kunde_email
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?`
)
.get(id);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const aufgaben = db
.prepare(`SELECT * FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge, erstellt_am`)
.all(id);
const aenderungen = db
.prepare(`SELECT * FROM wd_aenderungen WHERE projekt_id = ? ORDER BY erstellt_am DESC`)
.all(id);
const nachrichten = db
.prepare(`SELECT * FROM wd_nachrichten WHERE projekt_id = ? ORDER BY erstellt_am ASC`)
.all(id);
return json(res, {
ok: true,
projekt,
aufgaben,
aenderungen,
nachrichten,
fortschritt: fortschritt(id),
});
}
/* POST /webdesign/admin/projekte/:id/aufgaben — neuen Punkt anlegen */
export async function adminAufgabeAnlegen(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const titel = text(req.body?.titel, 200);
if (!titel) return json(res, { ok: false, error: "Bitte einen Titel angeben." }, 400);
const kategorie = KATEGORIEN.includes(req.body?.kategorie) ? req.body.kategorie : "umsetzung";
const werDran = WER_DRAN.includes(req.body?.werDran) ? req.body.werDran : "dogfather";
/* Neue Punkte landen am Ende ihrer Kategorie, nicht am Ende der Liste.
Sonst rutscht ein nachgetragener Gestaltungspunkt hinter den
Abschluss und die Liste erzählt eine falsche Reihenfolge. */
const basis = { start: 0, gestaltung: 100, umsetzung: 200, abschluss: 800 }[kategorie];
const maxInK = db
.prepare(
`SELECT MAX(reihenfolge) AS m FROM wd_aufgaben WHERE projekt_id = ? AND kategorie = ?`
)
.get(projekt.id, kategorie).m;
const reihenfolge = (maxInK != null ? maxInK : basis) + 10;
const id = neueId();
db.prepare(
`INSERT INTO wd_aufgaben
(id, projekt_id, kunde_id, titel, beschreibung, kategorie, status, wer_dran,
nicht_enthalten, hinweis, reihenfolge, erstellt_am)
VALUES (?,?,?,?,?,?,'offen',?,?,?,?,?)`
).run(
id, projekt.id, projekt.kunde_id, titel, text(req.body?.beschreibung, 600) || null,
kategorie, werDran, req.body?.nichtEnthalten ? 1 : 0,
text(req.body?.hinweis, 300) || null, reihenfolge, jetzt()
);
verlauf("aufgabe", id, "angelegt", werIst(p), titel);
return json(res, { ok: true, id, reihenfolge, fortschritt: fortschritt(projekt.id) });
}
/* POST /webdesign/admin/aufgaben/:id — Punkt ändern */
export async function adminAufgabeAendern(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const aufgabe = db.prepare(`SELECT * FROM wd_aufgaben WHERE id = ?`).get(String(req.params.id || ""));
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
const felder = [];
const werte = [];
const b = req.body || {};
if (typeof b.titel === "string") {
const t = text(b.titel, 200);
if (!t) return json(res, { ok: false, error: "Der Titel darf nicht leer sein." }, 400);
felder.push("titel = ?"); werte.push(t);
}
if (typeof b.beschreibung === "string") {
felder.push("beschreibung = ?"); werte.push(text(b.beschreibung, 600) || null);
}
if (typeof b.hinweis === "string") {
felder.push("hinweis = ?"); werte.push(text(b.hinweis, 300) || null);
}
if (KATEGORIEN.includes(b.kategorie)) {
felder.push("kategorie = ?"); werte.push(b.kategorie);
}
if (WER_DRAN.includes(b.werDran)) {
felder.push("wer_dran = ?"); werte.push(b.werDran);
}
if (b.nichtEnthalten !== undefined) {
felder.push("nicht_enthalten = ?"); werte.push(b.nichtEnthalten ? 1 : 0);
}
if (AUFGABE_STATUS.includes(b.status)) {
felder.push("status = ?"); werte.push(b.status);
/* erledigt_am wird beim Zurücksetzen geleert. Bliebe das Datum
stehen, zeigte die Liste später "erledigt am 3. März" bei einem
offenen Punkt — ein Widerspruch, den niemand mehr aufklärt. */
felder.push("erledigt_am = ?"); werte.push(b.status === "erledigt" ? jetzt() : null);
}
if (Number.isInteger(b.reihenfolge)) {
felder.push("reihenfolge = ?"); werte.push(b.reihenfolge);
}
if (!felder.length) return json(res, { ok: false, error: "Nichts zu ändern." }, 400);
felder.push("aktualisiert_am = ?"); werte.push(jetzt());
werte.push(aufgabe.id);
db.prepare(`UPDATE wd_aufgaben SET ${felder.join(", ")} WHERE id = ?`).run(...werte);
verlauf("aufgabe", aufgabe.id, "geaendert", werIst(p), aufgabe.titel);
return json(res, { ok: true, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* POST /webdesign/admin/aufgaben/:id/loeschen
Echtes Löschen, kein Archivieren — anders als bei Kunden und Anfragen.
Begründung: Ein versehentlich angelegter Punkt ist Arbeitsmaterial,
keine Geschäftsunterlage. Ein archivierter, aber unsichtbarer Punkt
wäre hier nur Ballast. Der Verlauf hält fest, dass es ihn gab. */
export async function adminAufgabeLoeschen(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const aufgabe = db.prepare(`SELECT * FROM wd_aufgaben WHERE id = ?`).get(String(req.params.id || ""));
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
db.prepare(`DELETE FROM wd_aufgaben WHERE id = ?`).run(aufgabe.id);
verlauf("aufgabe", aufgabe.id, "geloescht", werIst(p), aufgabe.titel);
return json(res, { ok: true, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* POST /webdesign/admin/projekte/:id/aufgaben/vorlage
Vorlage nachträglich einspielen — für Projekte, die vor dieser
Funktion angelegt wurden, oder wenn eine Liste versehentlich geleert
wurde. Fügt HINZU statt zu ersetzen: Vorhandene Punkte könnten schon
abgehakt sein, und ein Ersetzen würde diesen Fortschritt löschen. */
export async function adminVorlageAnwenden(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const paket = text(req.body?.paket, 40) || projekt.paket;
const vorlage = db
.prepare(
`SELECT titel, beschreibung, kategorie, wer_dran, reihenfolge
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge`
)
.all(paket);
if (!vorlage.length) {
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
}
/* Titel, die schon in der Liste stehen, werden übersprungen. Sonst
erzeugt ein zweiter Klick eine doppelte Liste — und das merkt man
erst, wenn der Kunde sie sieht. */
const vorhanden = new Set(
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projekt.id)
.map((z) => z.titel.toLowerCase())
);
const einfuegen = db.prepare(
`INSERT INTO wd_aufgaben
(id, projekt_id, kunde_id, titel, beschreibung, kategorie, status, wer_dran, reihenfolge, erstellt_am)
VALUES (?,?,?,?,?,?,'offen',?,?,?)`
);
let neu = 0;
db.transaction(() => {
for (const v of vorlage) {
if (vorhanden.has(v.titel.toLowerCase())) continue;
einfuegen.run(neueId(), projekt.id, projekt.kunde_id, v.titel, v.beschreibung,
v.kategorie, v.wer_dran, v.reihenfolge, jetzt());
neu++;
}
})();
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${neu} Punkte`);
return json(res, {
ok: true,
neu,
uebersprungen: vorlage.length - neu,
fortschritt: fortschritt(projekt.id),
});
}