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:
1 parent
93505e0e48
commit
f4fb3c550b
13 files changed
+1214
-116
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user