Projektleiter-Rolle, Papierkorb, neue Login-Seite und Benutzerverwaltung

- Neue Rolle "Projektleiter": Projekte anlegen und löschen nur noch für
  Projektleiter und Administratoren (serverseitig in /save-week geprüft)
- Papierkorb: gelöschte Projekte 30 Tage durch Admins wiederherstellbar
- Benutzerverwaltung: Aktionen im Drei-Punkte-Menü mit Rückfrage im Menü,
  getrennte Felder für Vor- und Nachname, E-Mail-Vorschlag
  v.nachname@rauschenberg-ing.de, Benutzername = Anfang der E-Mail,
  Person-ID aus dem Kürzel
- Endgültiges Löschen eines Benutzers entfernt ihn auch aus den Projekten
  und Aufgaben der aktuellen Sitzung; Dateien bleiben als
  "Gelöschter Benutzer"
- Login-Seite neu (Design "Kalenderwoche") mit Logo als Wasserzeichen;
  Anmeldung ohne Groß-/Kleinschreibung, auch mit voller E-Mail-Adresse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-22 09:25:33 +02:00
1 parent 93505e0e48
commit f4fb3c550b
13 files changed
+1214 -116

No files matched your search

+345 -24
View File
@@ -283,8 +283,18 @@ const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwach
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
// validiert wird).
const VALID_ROLES = new Set(['admin', 'member', 'kiosk']);
// validiert wird). 'projektleiter' ist ein Mitglied, das zusätzlich Projekte
// anlegen und aus der Projektliste löschen darf (siehe
// darfProjektlisteAendern()); sonst gilt
// für das Konto alles wie für 'member'. Nicht zu verwechseln mit der
// Fachrolle 'Projektleitung' (user_roles), die nur die Projektzuordnung
// betrifft und keine Berechtigung verleiht.
const VALID_ROLES = new Set(['admin', 'member', 'projektleiter', 'kiosk']);
// GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste
// gelöschtes Projekt im Papierkorb (deleted_projects) und ist für
// Administratoren wiederherstellbar; danach entfernt es
// purgeGeloeschteProjekte() endgültig.
const GELOESCHTE_PROJEKTE_TAGE = 30;
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
@@ -2577,6 +2587,29 @@ function migrateUsersDepartment(){
}
}
// migrateUsersVorNachname: Vor- und Nachname als eigene Spalten
// (first_name/last_name). Bis dahin gab es nur den Anzeigenamen; daraus
// ließen sich Benutzername und E-Mail-Adresse nicht verlässlich ableiten (ein
// einzelnes Wort im Feld ergab z. B. nur den Nachnamen). display_name bleibt
// bestehen und wird beim Anlegen/Bearbeiten als "Vorname Nachname" gesetzt —
// alle übrigen Stellen lesen weiter display_name. Bestandskonten bekommen
// Vor-/Nachname einmalig aus display_name (erstes Wort / Rest). Benutzernamen
// bleiben unverändert: Angemeldet wird mit dem Anfang der E-Mail-Adresse
// (z. B. e.dunin), und genau so heißen die Bestandskonten bereits.
function migrateUsersVorNachname(){
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
if(cols.includes('first_name') && cols.includes('last_name')) return;
if(!cols.includes('first_name')) db.exec("ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL DEFAULT ''");
if(!cols.includes('last_name')) db.exec("ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL DEFAULT ''");
const rows = db.prepare('SELECT id, display_name FROM users').all();
const setNamen = db.prepare('UPDATE users SET first_name = ?, last_name = ? WHERE id = ?');
rows.forEach(r => {
const woerter = String(r.display_name || '').trim().split(/\s+/).filter(Boolean);
setNamen.run(woerter[0] || '', woerter.slice(1).join(' '), r.id);
});
console.log('Benutzer-Tabelle um Vor-/Nachname ergänzt (' + rows.length + ' Konten aus dem Anzeigenamen befüllt).');
}
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
@@ -2692,6 +2725,7 @@ migrateAddProtokollPunktErstelltAm();
migrateAddProtokollPunktStatus();
migrateAddProtokollPunktEntfaelltNaechstes();
migrateUsersDepartment();
migrateUsersVorNachname();
ensureSandboxWeek();
ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers();
@@ -2722,6 +2756,41 @@ try {
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
}
// ---------- Papierkorb gelöschter Projekte ----------
// deleted_projects: ein Schnappschuss (das vollständige Projekt-Objekt als
// JSON, wie es /load-all liefert — inkl. Terminplan, Beteiligten, LPH) je
// gelöschtem Projekt. Bewusst JSON statt relational: Der Eintrag wird nie
// abgefragt oder ausgewertet, nur 1:1 wieder in eine Sitzung eingesetzt.
// Dateien und Protokolle hängen an project_nr (nicht an der Projekt-ID) und
// werden beim Löschen gar nicht angefasst — nach dem Wiederherstellen sind
// sie deshalb ohne weiteres Zutun wieder da.
db.exec(`CREATE TABLE IF NOT EXISTS deleted_projects (
id TEXT PRIMARY KEY,
week_label TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
project_uid TEXT NOT NULL DEFAULT '',
nr TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
deleted_by_user_id INTEGER NOT NULL DEFAULT 0,
deleted_by_name TEXT NOT NULL DEFAULT '',
deleted_at TEXT NOT NULL
)`);
// purgeGeloeschteProjekte: entfernt Papierkorb-Einträge, die älter als
// GELOESCHTE_PROJEKTE_TAGE sind. Läuft beim Start und stündlich mit dem
// Sitzungswechsel (siehe app.listen()).
function purgeGeloeschteProjekte(){
try {
const grenze = new Date(Date.now() - GELOESCHTE_PROJEKTE_TAGE * 24 * 60 * 60 * 1000).toISOString();
const r = db.prepare('DELETE FROM deleted_projects WHERE deleted_at < ?').run(grenze);
if(r && r.changes) console.log('Papierkorb: ' + r.changes + ' Projekt(e) nach ' + GELOESCHTE_PROJEKTE_TAGE + ' Tagen endgültig entfernt.');
} catch(err){
console.error('Aufräumen des Projekt-Papierkorbs fehlgeschlagen:', err.message);
}
}
purgeGeloeschteProjekte();
// ---------- Auth: Sessions, Middleware, Endpunkte ----------
const SESSION_COOKIE = 'puebersicht_session';
@@ -2880,7 +2949,12 @@ app.post('/login', (req, res) => {
if(loginIsBlocked(clientKey)){
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
}
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
// Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die
// ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg),
// und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin").
const eingabe = String(username).trim();
const kennung = eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe;
const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung);
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
const salt = row ? row.password_salt : 'unbekannt';
@@ -2989,6 +3063,8 @@ function serializeUserRow(row){
id: row.id,
username: row.username,
displayName: row.display_name,
firstName: row.first_name || '',
lastName: row.last_name || '',
email: row.email,
role: row.role,
personId: row.person_id,
@@ -3045,9 +3121,16 @@ app.get('/admin/users/:id/absences', requireRole('admin'), (req, res) => {
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
// passiert clientseitig).
app.post('/admin/users', requireRole('admin'), (req, res) => {
const { username, displayName, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
const firstName = String((req.body || {}).firstName || '').trim();
const lastName = String((req.body || {}).lastName || '').trim();
// Anzeigename = "Vorname Nachname"; ältere Clients schicken nur displayName.
const displayName = (firstName || lastName) ? [firstName, lastName].filter(Boolean).join(' ') : String((req.body || {}).displayName || '').trim();
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
if(!displayName || !String(displayName).trim()) return res.status(400).json({ ok: false, error: 'Anzeigename erforderlich.' });
if(!displayName) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
if(db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(String(username).trim())){
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
}
const finalRole = role ? String(role) : 'member';
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
@@ -3084,9 +3167,9 @@ app.post('/admin/users', requireRole('admin'), (req, res) => {
const now = new Date().toISOString();
try {
const info = db.prepare(`INSERT INTO users
(username, display_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
.run(String(username).trim(), String(displayName).trim(), String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
(username, display_name, first_name, last_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
.run(String(username).trim(), displayName, firstName, lastName, String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
@@ -3154,7 +3237,16 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => {
const fields = [];
const values = [];
if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
const { firstName, lastName } = req.body || {};
if(firstName !== undefined || lastName !== undefined){
// Vor-/Nachname setzen auch den Anzeigenamen — er ist ihre Verbindung.
const alt = db.prepare('SELECT first_name, last_name FROM users WHERE id = ?').get(id) || {};
const vn = firstName !== undefined ? String(firstName).trim() : (alt.first_name || '');
const nn = lastName !== undefined ? String(lastName).trim() : (alt.last_name || '');
if(!vn && !nn) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
fields.push('first_name = ?', 'last_name = ?', 'display_name = ?');
values.push(vn, nn, [vn, nn].filter(Boolean).join(' '));
} else if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
if(role !== undefined){ fields.push('role = ?'); values.push(role); }
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
@@ -3215,6 +3307,84 @@ app.post('/admin/users/:id/reset-password', requireRole('admin'), (req, res) =>
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
// weder soft- noch hard-löschen.
// loescheBenutzerEndgueltig: entfernt ein Konto samt seiner Verweise, in
// einer Transaktion. Auf Eriks Wunsch verschwindet die Person dabei aus den
// Projekten und damit aus der Auslastung — aber nur in der AKTUELLEN Sitzung
// (jüngste echte Woche) und in den Planung-Terminplänen. Ältere Sitzungen
// sind Historie (Verlauf im Wochenbericht) und bleiben unverändert.
// Welche Kennungen gemeint sind, bestimmt je Woche personenKennungenInWoche():
// users.person_id UND jede Kennung im Teilnehmerkreis dieser Woche mit
// demselben vollen Namen — Altbestand trägt Kurzkennungen (p_ap statt
// p_alexander_plum), an denen die Zuordnungen hängen; dieselbe Brücke
// über den Namen nutzt kapazitaetsFaktor() in public/js/load/load.js.
// Entfernt werden dort: Teilnehmerkreis
// (week_people/_roles), Anwesenheit, Projektzuordnungen (project_assign —
// daraus rechnet die Auslastung), Beteiligte des Terminplans (plan_people)
// und Aufgaben-Zuständigkeiten (plan_todo_assignees). Die betroffenen
// Wochen bekommen eine neue Revision, damit ein Browser mit altem Stand
// beim nächsten Speichern einen Konflikt bekommt und neu lädt, statt die
// Person wieder hineinzuschreiben.
// Dateien: Hochgeladene Dateien und Kommentare bleiben und erscheinen als
// „Gelöschter Benutzer" (LEFT JOIN + COALESCE in den Datei-Abfragen).
// Prüfaufträge an die Person werden entfernt und der Gesamtstatus der
// betroffenen, noch offenen Dateien neu berechnet (dieselbe Regel wie in
// POST /project-files/:id/review). Sitzungen, Rollen, Abwesenheiten und
// KI-Schlüssel fallen per ON DELETE CASCADE mit weg.
// Rückgabe: Zähler dessen, was entfernt wurde.
function personenKennungenInWoche(row, woche){
const ids = new Set();
if(row.person_id) ids.add(String(row.person_id));
const name = String(row.display_name || '').trim();
if(name){
db.prepare('SELECT person_id FROM week_people WHERE week_label = ? AND full_name = ?').all(woche, name)
.forEach(r => ids.add(r.person_id));
}
return [...ids].filter(Boolean);
}
function loescheBenutzerEndgueltig(row){
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
const wochen = [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean);
const zaehler = { zuordnungen: 0, aufgaben: 0, pruefauftraege: 0 };
const jetzt = new Date().toISOString();
db.exec('BEGIN');
try {
wochen.forEach(w => {
const ids = personenKennungenInWoche(row, w);
if(!ids.length) return;
ids.forEach(pid => {
zaehler.zuordnungen += db.prepare('DELETE FROM project_assign WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
zaehler.aufgaben += db.prepare('DELETE FROM plan_todo_assignees WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
['plan_people', 'week_people_roles', 'week_people', 'attendance'].forEach(t => {
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, pid);
});
});
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, w);
});
const dateien = db.prepare('SELECT file_id FROM project_file_reviewers WHERE user_id = ?').all(row.id).map(r => r.file_id);
zaehler.pruefauftraege = db.prepare('DELETE FROM project_file_reviewers WHERE user_id = ?').run(row.id).changes;
dateien.forEach(fileId => {
const datei = db.prepare('SELECT status FROM project_files WHERE id = ?').get(fileId);
if(!datei || datei.status !== 'pending') return;
const entscheidungen = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileId).map(r => r.decision);
let status = 'pending';
if(entscheidungen.some(d => d === 'rejected')) status = 'rejected';
else if(entscheidungen.length > 0 && entscheidungen.every(d => d === 'approved')) status = 'approved';
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, fileId);
});
db.prepare('DELETE FROM users WHERE id = ?').run(row.id);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
return zaehler;
}
app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
@@ -3224,17 +3394,9 @@ app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
const hard = req.query.hard === '1';
try {
if(hard){
const referenced = db.prepare(`SELECT
(SELECT COUNT(*) FROM project_files WHERE uploaded_by_user_id = ?) +
(SELECT COUNT(*) FROM project_file_reviewers WHERE user_id = ?) +
(SELECT COUNT(*) FROM project_file_comments WHERE user_id = ?) AS c`)
.get(id, id, id);
if(referenced.c > 0){
return res.status(409).json({ ok: false, error: 'Benutzer wird noch referenziert und kann nicht endgültig gelöscht werden.' });
}
// user_sessions dieses Users werden durch ON DELETE CASCADE automatisch mitgelöscht.
db.prepare('DELETE FROM users WHERE id = ?').run(id);
return res.json({ ok: true, hard: true });
const ergebnis = loescheBenutzerEndgueltig(row);
console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
}
const now = new Date().toISOString();
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
@@ -3311,9 +3473,30 @@ app.post('/save-week', requireAuth, (req, res) => {
});
}
}
// Angelegte und gelöschte Projekte erkennen: /save-week schreibt immer
// die ganze Woche, beides ist also nur am Vergleich mit dem gespeicherten
// Stand zu sehen (projektlisteAenderungen()). Nur Projektleiter und
// Administratoren dürfen das (403 sonst, bevor irgendetwas geschrieben
// wird); gelöschte Projekte wandern in den Papierkorb
// (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre
// Terminpläne sind keine Projekte der Projektliste und werden dort von
// jedem angelegt und gelöscht.
const aenderung = week.label === SANDBOX_WEEK_LABEL
? { neu: [], entfernt: [] } : projektlisteAenderungen(week);
const entfernt = aenderung.entfernt;
if((aenderung.neu.length || entfernt.length) && !darfProjektlisteAendern(req.user)){
return res.status(403).json({
ok: false,
error: aenderung.neu.length
? 'Projekte anlegen dürfen nur Projektleiter und Administratoren.'
: 'Projekte löschen dürfen nur Projektleiter und Administratoren.'
});
}
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week);
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
console.log('Saved week:', week.label);
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
} catch(err){
@@ -3322,6 +3505,142 @@ app.post('/save-week', requireAuth, (req, res) => {
}
});
// darfProjektlisteAendern: Systemrollen, die Projekte anlegen und aus der
// Projektliste löschen dürfen. Dieselbe Regel steht clientseitig in
// darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate.
function darfProjektlisteAendern(user){
return !!user && (user.role === 'admin' || user.role === 'projektleiter');
}
// projektlisteAenderungen: vergleicht die eingehende Woche mit dem
// gespeicherten Stand. Ein Projekt gilt als dasselbe, wenn seine ID ODER
// seine projectUid übereinstimmt. Rückgabe:
// neu — eingehende Projekte ohne Gegenstück (angelegt)
// entfernt — vollständige gespeicherte Projekt-Objekte (aus loadWeeks())
// ohne Gegenstück (gelöscht); der teure loadWeeks()-Aufruf läuft
// nur, wenn die günstige Vorprüfung überhaupt eines findet.
// Neue Sitzungen legt nur der Server selbst an (ensureCurrentWeekRollover(),
// ohne /save-week) — eine über /save-week neu eingehende Woche mit Projekten
// zählt deshalb ebenfalls als Anlegen.
function projektlisteAenderungen(week){
const label = String(week.label);
const gespeichert = db.prepare('SELECT project_id, project_uid FROM projects WHERE week_label = ?').all(label);
const eingehend = (Array.isArray(week.projects) ? week.projects : []).filter(Boolean);
const altIds = new Set(gespeichert.map(r => r.project_id));
const altUids = new Set(gespeichert.map(r => r.project_uid).filter(Boolean));
const neu = eingehend.filter(p => !altIds.has(String(p.id || '')) && !(p.projectUid && altUids.has(String(p.projectUid))));
const ids = new Set(eingehend.map(p => String(p.id || '')).filter(Boolean));
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
if(!fehlend.length) return { neu, entfernt: [] };
const w = loadWeeks().find(x => x.label === label);
const fehlendIds = new Set(fehlend.map(r => r.project_id));
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
}
// legeInPapierkorb: schreibt je gelöschtem Projekt einen Schnappschuss nach
// deleted_projects. Läuft NACH dem erfolgreichen Speichern der Woche — ein
// fehlgeschlagenes Speichern darf keinen Papierkorb-Eintrag für ein Projekt
// hinterlassen, das in Wahrheit noch da ist.
function legeInPapierkorb(projekte, weekLabel, user){
const ins = db.prepare(`INSERT INTO deleted_projects
(id, week_label, project_id, project_uid, nr, name, data, deleted_by_user_id, deleted_by_name, deleted_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`);
const jetzt = new Date().toISOString();
projekte.forEach(p => {
try {
ins.run(crypto.randomUUID(), String(weekLabel), String(p.id || ''), String(p.projectUid || ''),
String(p.nr || ''), String(p.name || ''), JSON.stringify(p),
user ? Number(user.id) || 0 : 0, user ? String(user.displayName || user.username || '') : '', jetzt);
console.log('Projekt in den Papierkorb gelegt:', ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim());
} catch(err){
console.error('Papierkorb-Eintrag fehlgeschlagen:', err.message);
}
});
}
// Route: Papierkorb — listet die gelöschten Projekte der letzten
// GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst).
app.get('/admin/deleted-projects', requireRole('admin'), (req, res) => {
try {
purgeGeloeschteProjekte();
const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at
FROM deleted_projects ORDER BY deleted_at DESC`).all();
const tagMs = 24 * 60 * 60 * 1000;
res.json({
ok: true,
aufbewahrungTage: GELOESCHTE_PROJEKTE_TAGE,
projekte: rows.map(r => ({
id: r.id, weekLabel: r.week_label, nr: r.nr, name: r.name,
deletedBy: r.deleted_by_name, deletedAt: r.deleted_at,
endgueltigAm: new Date(Date.parse(r.deleted_at) + GELOESCHTE_PROJEKTE_TAGE * tagMs).toISOString()
}))
});
} catch(err){
console.error('Papierkorb laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — stellt ein gelöschtes Projekt wieder her. Ziel ist
// immer die aktuelle (jüngste echte) Sitzung, nicht die, aus der es gelöscht
// wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit
// einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien
// und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten).
app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id));
if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' });
const echte = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL)
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
const ziel = echte[echte.length - 1];
if(!ziel) return res.status(409).json({ ok: false, error: 'Es gibt keine Sitzung, in die das Projekt zurückkehren könnte.' });
const projekt = JSON.parse(row.data);
const projekte = Array.isArray(ziel.projects) ? ziel.projects : (ziel.projects = []);
const nr = String(projekt.nr || '').trim();
if(nr && projekte.some(p => String(p.nr || '').trim() === nr)){
return res.status(409).json({ ok: false, error: 'In der Sitzung "' + ziel.label + '" gibt es bereits ein Projekt mit der Nummer ' + nr + '.' });
}
if(projekt.projectUid && projekte.some(p => p.projectUid === projekt.projectUid)){
return res.status(409).json({ ok: false, error: 'Dieses Projekt steht bereits in der Sitzung "' + ziel.label + '".' });
}
// Neue ID, falls die alte in der Zielsitzung schon vergeben ist (nach
// einem Sitzungswechsel praktisch ausgeschlossen, aber billig abzusichern).
if(!projekt.id || projekte.some(p => p.id === projekt.id)) projekt.id = crypto.randomUUID();
// Ans Ende seiner Gruppe — die alte Position kann inzwischen belegt sein.
const maxOrder = projekte.filter(p => p.group === projekt.group)
.reduce((m, p) => Math.max(m, Number(p.order) || 0), -1);
projekt.order = maxOrder + 1;
// Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt.
if(row.week_label !== ziel.label){
projekt.status = ''; projekt.statusAuthor = ''; projekt.statusAt = '';
projekt.besprochen = false; delete projekt.statusState;
}
projekte.push(projekt);
const updatedAt = saveWeekRelational(ziel);
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id);
console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label);
res.json({ ok: true, weekLabel: ziel.label, updatedAt });
} catch(err){
console.error('Wiederherstellen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — entfernt einen Eintrag sofort endgültig.
app.delete('/admin/deleted-projects/:id', requireRole('admin'), (req, res) => {
try {
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
app.post('/delete-week', requireRole('admin'), (req, res) => {
const { label } = req.body || {};
@@ -3620,7 +3939,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
const pendingReviews = db.prepare(`
SELECT f.id AS fileId, f.project_nr AS projectNr,
f.project_name AS projectName, f.original_filename AS originalFilename,
COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy,
f.created_at AS createdAt
FROM project_file_reviewers r
JOIN project_files f ON f.id = r.file_id
@@ -3873,14 +4192,14 @@ app.get('/project-files', requireAuth, (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
const rows = db.prepare(`
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy
FROM project_files f
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
WHERE f.project_nr = ?
ORDER BY f.created_at DESC
`).all(projectNr);
const reviewerStmt = db.prepare(`
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName, r.decision AS decision
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName, r.decision AS decision
FROM project_file_reviewers r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.file_id = ?
@@ -3964,7 +4283,7 @@ app.post('/project-files/:id/review', requireAuth, (req, res) => {
app.get('/project-files/:id/comments', requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName,
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName,
c.body AS body, c.created_at AS createdAt
FROM project_file_comments c
LEFT JOIN users u ON u.id = c.user_id
@@ -6558,6 +6877,8 @@ const server = app.listen(PORT, () => {
// Reihenfolge weiter oben.
ensureCurrentWeekRollover();
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
});
server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message);