Benutzerverwaltung mit Rollen/Rechten und Urlaubsplanung
Führt Login mit vier Rollen (Admin, Teamleiter, Mitarbeiter, Beobachter) ein, über die Teammitglieder eigenen Urlaub inkl. Pflicht-Vertreter eintragen können. Die Auslastungsberechnung berücksichtigt Urlaub jetzt als reduzierte Kapazität. Neue Konten vergeben ihr Passwort selbst beim ersten Login. Migrationsskript für bestehende Mitarbeiter aus der Vorversion inklusive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('./db');
|
||||
|
||||
const SESSION_LIFETIME_DAYS = 30;
|
||||
|
||||
function hashPassword(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
}
|
||||
|
||||
function verifyPassword(password, hash) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
}
|
||||
|
||||
function createSession(userId) {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_LIFETIME_DAYS} days'))`
|
||||
).run(token, userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.get('authorization') || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'nicht angemeldet' });
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.employee_id, u.email, u.role FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > datetime('now')`
|
||||
)
|
||||
.get(token);
|
||||
if (!row) return res.status(401).json({ error: 'Sitzung ungültig oder abgelaufen' });
|
||||
|
||||
req.user = row;
|
||||
req.sessionToken = token;
|
||||
next();
|
||||
}
|
||||
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { hashPassword, verifyPassword, createSession, requireAuth, requireRole };
|
||||
@@ -15,6 +15,7 @@ const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
migrateEmployeeName();
|
||||
seedInitialAdmin();
|
||||
|
||||
if (isNew) {
|
||||
seed();
|
||||
@@ -41,6 +42,41 @@ function migrateEmployeeName() {
|
||||
db.exec('ALTER TABLE employees DROP COLUMN name');
|
||||
}
|
||||
|
||||
// Legt beim allerersten Start einen Admin-Zugang für einen BESTEHENDEN Mitarbeiter an,
|
||||
// dessen employees.email mit ADMIN_EMAIL übereinstimmt. Es wird bewusst kein Passwort
|
||||
// gesetzt: wie bei allen über die Benutzerverwaltung angelegten Konten wird das
|
||||
// Passwort erst beim allerersten Login vom Nutzer selbst vergeben (siehe /api/auth/login).
|
||||
function seedInitialAdmin() {
|
||||
const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
if (userCount > 0) return;
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
if (!adminEmail) {
|
||||
console.log(
|
||||
'Hinweis: ADMIN_EMAIL ist nicht gesetzt, es wurde kein Start-Admin angelegt. ' +
|
||||
'Bitte ADMIN_EMAIL auf die E-Mail eines bestehenden Mitarbeiters setzen und neu starten, ' +
|
||||
'oder scripts/reset-admin.js verwenden.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const employee = db.prepare('SELECT id FROM employees WHERE email = ?').get(adminEmail);
|
||||
if (!employee) {
|
||||
console.log(
|
||||
`Hinweis: ADMIN_EMAIL (${adminEmail}) passt zu keinem bestehenden Mitarbeiter, ` +
|
||||
'es wurde kein Start-Admin angelegt.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare('INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)').run(
|
||||
employee.id,
|
||||
adminEmail,
|
||||
'admin'
|
||||
);
|
||||
console.log(`Start-Admin für ${adminEmail} angelegt. Das Passwort wird beim ersten Login vergeben.`);
|
||||
}
|
||||
|
||||
function seed() {
|
||||
const insertEmployee = db.prepare(
|
||||
'INSERT INTO employees (first_name, last_name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?, ?)'
|
||||
|
||||
+300
-28
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const db = require('./db');
|
||||
const { startBackupScheduler, runBackup } = require('./backup');
|
||||
const { hashPassword, verifyPassword, createSession, requireAuth, requireRole } = require('./auth');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -9,6 +10,43 @@ app.use(express.json());
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
|
||||
// ---------- auth ----------
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'email und password sind erforderlich' });
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'E-Mail oder Passwort falsch' });
|
||||
}
|
||||
|
||||
if (user.password_hash === null) {
|
||||
// Erster Login für dieses Konto: das eingegebene Passwort wird als neues Passwort übernommen.
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), user.id);
|
||||
} else if (!verifyPassword(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'E-Mail oder Passwort falsch' });
|
||||
}
|
||||
|
||||
const token = createSession(user.id);
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, employee_id: user.employee_id, email: user.email, role: user.role },
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', requireAuth);
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(req.sessionToken);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function isoWeekKey(date) {
|
||||
@@ -45,7 +83,7 @@ app.get('/api/employees', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM employees ORDER BY last_name, first_name').all());
|
||||
});
|
||||
|
||||
app.post('/api/employees', (req, res) => {
|
||||
app.post('/api/employees', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
||||
if (!first_name || !last_name) {
|
||||
return res.status(400).json({ error: 'first_name und last_name sind erforderlich' });
|
||||
@@ -58,7 +96,7 @@ app.post('/api/employees', (req, res) => {
|
||||
res.status(201).json(db.prepare('SELECT * FROM employees WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
app.put('/api/employees/:id', (req, res) => {
|
||||
app.put('/api/employees/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
||||
const existing = db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
@@ -75,7 +113,7 @@ app.put('/api/employees/:id', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/employees/:id', (req, res) => {
|
||||
app.delete('/api/employees/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM employees WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -98,7 +136,7 @@ app.get('/api/teams', (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/teams', (req, res) => {
|
||||
app.post('/api/teams', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
||||
const result = db
|
||||
@@ -107,7 +145,7 @@ app.post('/api/teams', (req, res) => {
|
||||
res.status(201).json({ ...db.prepare('SELECT * FROM teams WHERE id = ?').get(result.lastInsertRowid), members: [] });
|
||||
});
|
||||
|
||||
app.put('/api/teams/:id', (req, res) => {
|
||||
app.put('/api/teams/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
const existing = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
@@ -119,12 +157,12 @@ app.put('/api/teams/:id', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/teams/:id', (req, res) => {
|
||||
app.delete('/api/teams/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post('/api/teams/:id/members', (req, res) => {
|
||||
app.post('/api/teams/:id/members', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { employee_id } = req.body;
|
||||
db.prepare('INSERT OR IGNORE INTO team_members (team_id, employee_id) VALUES (?, ?)').run(
|
||||
req.params.id,
|
||||
@@ -133,7 +171,7 @@ app.post('/api/teams/:id/members', (req, res) => {
|
||||
res.status(201).end();
|
||||
});
|
||||
|
||||
app.delete('/api/teams/:id/members/:employeeId', (req, res) => {
|
||||
app.delete('/api/teams/:id/members/:employeeId', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM team_members WHERE team_id = ? AND employee_id = ?').run(
|
||||
req.params.id,
|
||||
req.params.employeeId
|
||||
@@ -167,7 +205,7 @@ app.get('/api/projects', (req, res) => {
|
||||
res.json(tasksByProject);
|
||||
});
|
||||
|
||||
app.post('/api/projects', (req, res) => {
|
||||
app.post('/api/projects', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { name, description, start_date, end_date, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
||||
const result = db
|
||||
@@ -178,7 +216,7 @@ app.post('/api/projects', (req, res) => {
|
||||
res.status(201).json(db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
app.put('/api/projects/:id', (req, res) => {
|
||||
app.put('/api/projects/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
const { name, description, start_date, end_date, color } = req.body;
|
||||
@@ -195,12 +233,12 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id', (req, res) => {
|
||||
app.delete('/api/projects/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post('/api/tasks', (req, res) => {
|
||||
app.post('/api/tasks', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { project_id, name, start_date, end_date, estimated_hours, status } = req.body;
|
||||
if (!project_id || !name || !start_date || !end_date) {
|
||||
return res.status(400).json({ error: 'project_id, name, start_date, end_date sind erforderlich' });
|
||||
@@ -213,9 +251,23 @@ app.post('/api/tasks', (req, res) => {
|
||||
res.status(201).json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
app.put('/api/tasks/:id', (req, res) => {
|
||||
app.put('/api/tasks/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
|
||||
if (req.user.role === 'mitarbeiter') {
|
||||
const assigned = db
|
||||
.prepare('SELECT 1 FROM assignments WHERE task_id = ? AND employee_id = ?')
|
||||
.get(req.params.id, req.user.employee_id);
|
||||
if (!assigned) return res.status(403).json({ error: 'nicht zugewiesen' });
|
||||
const keys = Object.keys(req.body);
|
||||
if (keys.length === 0 || keys.some((k) => k !== 'status')) {
|
||||
return res.status(400).json({ error: 'Mitarbeiter dürfen nur den Status ändern' });
|
||||
}
|
||||
db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(req.body.status, req.params.id);
|
||||
return res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
|
||||
}
|
||||
|
||||
const { name, start_date, end_date, estimated_hours, status } = req.body;
|
||||
db.prepare(
|
||||
'UPDATE tasks SET name = ?, start_date = ?, end_date = ?, estimated_hours = ?, status = ? WHERE id = ?'
|
||||
@@ -230,12 +282,12 @@ app.put('/api/tasks/:id', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/tasks/:id', (req, res) => {
|
||||
app.delete('/api/tasks/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post('/api/tasks/:id/duplicate', (req, res) => {
|
||||
app.post('/api/tasks/:id/duplicate', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
const { start_date, end_date } = req.body;
|
||||
@@ -267,7 +319,7 @@ app.post('/api/tasks/:id/duplicate', (req, res) => {
|
||||
res.status(201).json({ ...newTask, assignments: newAssignments, depends_on: [] });
|
||||
});
|
||||
|
||||
app.post('/api/tasks/:id/dependencies', (req, res) => {
|
||||
app.post('/api/tasks/:id/dependencies', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { depends_on_task_id } = req.body;
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)'
|
||||
@@ -275,7 +327,7 @@ app.post('/api/tasks/:id/dependencies', (req, res) => {
|
||||
res.status(201).end();
|
||||
});
|
||||
|
||||
app.delete('/api/tasks/:id/dependencies/:dependsOnId', (req, res) => {
|
||||
app.delete('/api/tasks/:id/dependencies/:dependsOnId', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare(
|
||||
'DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?'
|
||||
).run(req.params.id, req.params.dependsOnId);
|
||||
@@ -284,7 +336,7 @@ app.delete('/api/tasks/:id/dependencies/:dependsOnId', (req, res) => {
|
||||
|
||||
// ---------- assignments ----------
|
||||
|
||||
app.post('/api/assignments', (req, res) => {
|
||||
app.post('/api/assignments', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { task_id, employee_id, allocated_hours_per_week } = req.body;
|
||||
if (!task_id || !employee_id) {
|
||||
return res.status(400).json({ error: 'task_id und employee_id sind erforderlich' });
|
||||
@@ -301,7 +353,7 @@ app.post('/api/assignments', (req, res) => {
|
||||
res.status(201).json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
app.put('/api/assignments/:id', (req, res) => {
|
||||
app.put('/api/assignments/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
const { allocated_hours_per_week } = req.body;
|
||||
db.prepare('UPDATE assignments SET allocated_hours_per_week = ? WHERE id = ?').run(
|
||||
allocated_hours_per_week,
|
||||
@@ -310,11 +362,146 @@ app.put('/api/assignments/:id', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/assignments/:id', (req, res) => {
|
||||
app.delete('/api/assignments/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- vacations ----------
|
||||
|
||||
function findVacationConflict({ role, substituteEmployeeId, startDate, endDate, excludeId }) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT v.id FROM vacations v
|
||||
JOIN employees e ON e.id = v.employee_id
|
||||
JOIN users u ON u.employee_id = e.id
|
||||
WHERE u.role = ? AND v.substitute_employee_id = ? AND v.id != ?
|
||||
AND v.start_date <= ? AND v.end_date >= ?`
|
||||
)
|
||||
.get(role, substituteEmployeeId, excludeId, endDate, startDate);
|
||||
}
|
||||
|
||||
function vacationRow(id) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT v.*,
|
||||
(emp.first_name || ' ' || emp.last_name) AS employee_name,
|
||||
(sub.first_name || ' ' || sub.last_name) AS substitute_name
|
||||
FROM vacations v
|
||||
JOIN employees emp ON emp.id = v.employee_id
|
||||
JOIN employees sub ON sub.id = v.substitute_employee_id
|
||||
WHERE v.id = ?`
|
||||
)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
app.get('/api/vacations', (req, res) => {
|
||||
res.json(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT v.*,
|
||||
(emp.first_name || ' ' || emp.last_name) AS employee_name,
|
||||
(sub.first_name || ' ' || sub.last_name) AS substitute_name
|
||||
FROM vacations v
|
||||
JOIN employees emp ON emp.id = v.employee_id
|
||||
JOIN employees sub ON sub.id = v.substitute_employee_id
|
||||
ORDER BY v.start_date DESC`
|
||||
)
|
||||
.all()
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/vacations', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||
let { employee_id, start_date, end_date, substitute_employee_id, note } = req.body;
|
||||
if (req.user.role === 'mitarbeiter') {
|
||||
employee_id = req.user.employee_id;
|
||||
}
|
||||
if (!employee_id || !start_date || !end_date || !substitute_employee_id) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'employee_id, start_date, end_date und substitute_employee_id sind erforderlich' });
|
||||
}
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: 'start_date darf nicht nach end_date liegen' });
|
||||
}
|
||||
if (Number(substitute_employee_id) === Number(employee_id)) {
|
||||
return res.status(400).json({ error: 'Vertreter darf nicht die urlaubende Person selbst sein' });
|
||||
}
|
||||
|
||||
const conflict = findVacationConflict({
|
||||
role: req.user.role,
|
||||
substituteEmployeeId: substitute_employee_id,
|
||||
startDate: start_date,
|
||||
endDate: end_date,
|
||||
excludeId: -1,
|
||||
});
|
||||
if (conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: 'Vertreter ist bereits für einen überlappenden Zeitraum in dieser Rolle eingetragen' });
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO vacations (employee_id, start_date, end_date, substitute_employee_id, note) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(employee_id, start_date, end_date, substitute_employee_id, note || null);
|
||||
res.status(201).json(vacationRow(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
app.put('/api/vacations/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM vacations WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
if (req.user.role === 'mitarbeiter' && existing.employee_id !== req.user.employee_id) {
|
||||
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||
}
|
||||
|
||||
const start_date = req.body.start_date ?? existing.start_date;
|
||||
const end_date = req.body.end_date ?? existing.end_date;
|
||||
const substitute_employee_id = req.body.substitute_employee_id ?? existing.substitute_employee_id;
|
||||
const note = req.body.note ?? existing.note;
|
||||
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: 'start_date darf nicht nach end_date liegen' });
|
||||
}
|
||||
if (Number(substitute_employee_id) === Number(existing.employee_id)) {
|
||||
return res.status(400).json({ error: 'Vertreter darf nicht die urlaubende Person selbst sein' });
|
||||
}
|
||||
|
||||
const requesterRole = db
|
||||
.prepare('SELECT role FROM users WHERE employee_id = ?')
|
||||
.get(existing.employee_id)?.role;
|
||||
if (requesterRole) {
|
||||
const conflict = findVacationConflict({
|
||||
role: requesterRole,
|
||||
substituteEmployeeId: substitute_employee_id,
|
||||
startDate: start_date,
|
||||
endDate: end_date,
|
||||
excludeId: existing.id,
|
||||
});
|
||||
if (conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: 'Vertreter ist bereits für einen überlappenden Zeitraum in dieser Rolle eingetragen' });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
'UPDATE vacations SET start_date = ?, end_date = ?, substitute_employee_id = ?, note = ? WHERE id = ?'
|
||||
).run(start_date, end_date, substitute_employee_id, note, req.params.id);
|
||||
res.json(vacationRow(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/vacations/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM vacations WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
if (req.user.role === 'mitarbeiter' && existing.employee_id !== req.user.employee_id) {
|
||||
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||
}
|
||||
db.prepare('DELETE FROM vacations WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- utilization heatmap ----------
|
||||
|
||||
app.get('/api/utilization', (req, res) => {
|
||||
@@ -334,24 +521,42 @@ app.get('/api/utilization', (req, res) => {
|
||||
FROM assignments a JOIN tasks t ON t.id = a.task_id`
|
||||
)
|
||||
.all();
|
||||
const vacationRows = db.prepare('SELECT employee_id, start_date, end_date FROM vacations').all();
|
||||
|
||||
function overlapDaysInWeek(startStr, endStr, week) {
|
||||
const rangeStart = parseDate(startStr);
|
||||
const rangeEnd = parseDate(endStr);
|
||||
const weekEnd = addDays(week.start, 6);
|
||||
const overlapStart = rangeStart > week.start ? rangeStart : week.start;
|
||||
const overlapEnd = rangeEnd < weekEnd ? rangeEnd : weekEnd;
|
||||
return Math.max(0, Math.round((overlapEnd - overlapStart) / 86400000) + 1);
|
||||
}
|
||||
|
||||
const data = {};
|
||||
for (const e of employees) data[e.id] = {};
|
||||
const vacationDays = {};
|
||||
for (const e of employees) {
|
||||
data[e.id] = {};
|
||||
vacationDays[e.id] = {};
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const taskStart = parseDate(row.start_date);
|
||||
const taskEnd = parseDate(row.end_date);
|
||||
for (const week of weeks) {
|
||||
const weekEnd = addDays(week.start, 6);
|
||||
const overlapStart = taskStart > week.start ? taskStart : week.start;
|
||||
const overlapEnd = taskEnd < weekEnd ? taskEnd : weekEnd;
|
||||
const overlapDays = Math.max(0, Math.round((overlapEnd - overlapStart) / 86400000) + 1);
|
||||
const overlapDays = overlapDaysInWeek(row.start_date, row.end_date, week);
|
||||
if (overlapDays <= 0) continue;
|
||||
const hours = row.allocated_hours_per_week * (overlapDays / 7);
|
||||
data[row.employee_id][week.key] = (data[row.employee_id][week.key] || 0) + hours;
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of vacationRows) {
|
||||
if (!vacationDays[row.employee_id]) continue;
|
||||
for (const week of weeks) {
|
||||
const overlapDays = overlapDaysInWeek(row.start_date, row.end_date, week);
|
||||
if (overlapDays <= 0) continue;
|
||||
vacationDays[row.employee_id][week.key] = (vacationDays[row.employee_id][week.key] || 0) + overlapDays;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
weeks: weeks.map((w) => w.key),
|
||||
employees: employees.map((e) => ({
|
||||
@@ -359,6 +564,10 @@ app.get('/api/utilization', (req, res) => {
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
weekly_capacity_hours: e.weekly_capacity_hours,
|
||||
hours: weeks.map((w) => Math.round((data[e.id][w.key] || 0) * 10) / 10),
|
||||
capacity: weeks.map((w) => {
|
||||
const days = Math.min(7, vacationDays[e.id][w.key] || 0);
|
||||
return Math.round(e.weekly_capacity_hours * (1 - days / 7) * 10) / 10;
|
||||
}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -367,7 +576,7 @@ app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
// ---------- backup ----------
|
||||
|
||||
app.post('/api/backup', async (req, res) => {
|
||||
app.post('/api/backup', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const file = await runBackup();
|
||||
res.status(201).json({ file });
|
||||
@@ -376,6 +585,59 @@ app.post('/api/backup', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- users (Benutzerverwaltung) ----------
|
||||
|
||||
app.get('/api/users', requireRole('admin'), (req, res) => {
|
||||
res.json(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT u.id, u.employee_id, u.email, u.role,
|
||||
(e.first_name || ' ' || e.last_name) AS employee_name
|
||||
FROM users u LEFT JOIN employees e ON e.id = u.employee_id
|
||||
ORDER BY u.email`
|
||||
)
|
||||
.all()
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/users', requireRole('admin'), (req, res) => {
|
||||
const { employee_id, email, role } = req.body;
|
||||
if (!email || !role) {
|
||||
return res.status(400).json({ error: 'email und role sind erforderlich' });
|
||||
}
|
||||
// Passwort wird bewusst nicht hier gesetzt: der Mitarbeiter vergibt es selbst beim ersten Login.
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)')
|
||||
.run(employee_id || null, email, role);
|
||||
res.status(201).json(
|
||||
db
|
||||
.prepare('SELECT id, employee_id, email, role FROM users WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
);
|
||||
});
|
||||
|
||||
app.put('/api/users/:id', requireRole('admin'), (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||
const email = req.body.email ?? existing.email;
|
||||
const role = req.body.role ?? existing.role;
|
||||
// reset_password: true setzt den Hash zurück auf NULL, das Konto vergibt beim
|
||||
// nächsten Login wieder ein neues Passwort (gleicher Mechanismus wie bei Neuanlage).
|
||||
const passwordHash = req.body.reset_password ? null : existing.password_hash;
|
||||
db.prepare('UPDATE users SET email = ?, role = ?, password_hash = ? WHERE id = ?').run(
|
||||
email,
|
||||
role,
|
||||
passwordHash,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT id, employee_id, email, role FROM users WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', requireRole('admin'), (req, res) => {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// serve built frontend in production
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -387,6 +649,16 @@ if (fs.existsSync(clientDist)) {
|
||||
});
|
||||
}
|
||||
|
||||
// generischer Fehler-Handler: SQLite-Fremdschlüsselverletzungen (z. B. Mitarbeiter
|
||||
// löschen, der noch als Urlaubsvertreter eingetragen ist) sauber als 409 statt 500 melden
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
|
||||
return res.status(409).json({ error: 'Datensatz wird an anderer Stelle noch referenziert' });
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'interner Fehler' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Ressourcenplanung-API läuft auf http://localhost:${PORT}`);
|
||||
startBackupScheduler();
|
||||
|
||||
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "ressourcenplanung-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2"
|
||||
@@ -52,6 +53,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2"
|
||||
|
||||
@@ -53,3 +53,29 @@ CREATE TABLE IF NOT EXISTS assignments (
|
||||
allocated_hours_per_week REAL NOT NULL DEFAULT 0,
|
||||
UNIQUE (task_id, employee_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER UNIQUE REFERENCES employees(id) ON DELETE SET NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'mitarbeiter' CHECK (role IN ('admin','teamleiter','mitarbeiter','beobachter'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vacations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
substitute_employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacations_employee ON vacations(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacations_substitute ON vacations(substitute_employee_id);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Einmalig beim Umstieg von der alten Version (ohne Login) auf die neue Version
|
||||
// (mit Benutzerverwaltung) auszuführen: legt für jeden bestehenden Mitarbeiter
|
||||
// mit hinterlegter E-Mail, der noch KEIN Benutzerkonto hat, automatisch eines an
|
||||
// (Rolle 'mitarbeiter', ohne Passwort — wie bei der Benutzerverwaltung üblich wird
|
||||
// das Passwort beim ersten Login vom jeweiligen Mitarbeiter selbst vergeben).
|
||||
//
|
||||
// Bestehende Projekte, Aufgaben, Zuweisungen und Teams werden dabei nicht
|
||||
// verändert — die Tabellen werden vom Server ohnehin additiv angelegt
|
||||
// (server/schema.sql, CREATE TABLE IF NOT EXISTS). Dieses Skript kümmert sich
|
||||
// ausschließlich darum, dass alle bestehenden Teammitglieder auch einen Login
|
||||
// bekommen, statt sie einzeln über die Benutzerverwaltung anlegen zu müssen.
|
||||
//
|
||||
// Aufruf:
|
||||
// node scripts/migrate-team-accounts.js
|
||||
// docker exec <container> node scripts/migrate-team-accounts.js (im laufenden Container)
|
||||
//
|
||||
// Rolle abweichend vom Default vergeben (z. B. alle als Teamleiter anlegen):
|
||||
// node scripts/migrate-team-accounts.js --role teamleiter
|
||||
|
||||
const db = require('../db');
|
||||
|
||||
function readArg(name) {
|
||||
const idx = process.argv.indexOf(`--${name}`);
|
||||
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
||||
}
|
||||
|
||||
const role = readArg('role') || 'mitarbeiter';
|
||||
const VALID_ROLES = ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'];
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
console.error(`Ungültige Rolle "${role}". Erlaubt: ${VALID_ROLES.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const employees = db.prepare('SELECT id, first_name, last_name, email FROM employees ORDER BY last_name, first_name').all();
|
||||
const existingUsers = db.prepare('SELECT employee_id, email FROM users').all();
|
||||
const employeeIdsWithAccount = new Set(existingUsers.map((u) => u.employee_id));
|
||||
const emailsInUse = new Set(existingUsers.map((u) => u.email));
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)'
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let skippedNoEmail = 0;
|
||||
let skippedExisting = 0;
|
||||
let skippedEmailConflict = 0;
|
||||
|
||||
for (const e of employees) {
|
||||
const name = `${e.first_name} ${e.last_name}`;
|
||||
|
||||
if (employeeIdsWithAccount.has(e.id)) {
|
||||
skippedExisting += 1;
|
||||
continue;
|
||||
}
|
||||
if (!e.email) {
|
||||
console.log(`Übersprungen (keine E-Mail hinterlegt): ${name}`);
|
||||
skippedNoEmail += 1;
|
||||
continue;
|
||||
}
|
||||
if (emailsInUse.has(e.email)) {
|
||||
console.log(`Übersprungen (E-Mail bereits von einem anderen Konto verwendet): ${name} <${e.email}>`);
|
||||
skippedEmailConflict += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
insert.run(e.id, e.email, role);
|
||||
emailsInUse.add(e.email);
|
||||
created += 1;
|
||||
console.log(`Konto angelegt: ${name} <${e.email}> (Rolle: ${role})`);
|
||||
}
|
||||
|
||||
console.log('----------------------------------------------------------------');
|
||||
console.log(`Fertig. Neu angelegt: ${created}, bereits vorhanden: ${skippedExisting}, ` +
|
||||
`ohne E-Mail übersprungen: ${skippedNoEmail}, E-Mail-Konflikt übersprungen: ${skippedEmailConflict}.`);
|
||||
console.log('Alle neu angelegten Konten vergeben ihr Passwort selbst beim ersten Login.');
|
||||
@@ -0,0 +1,52 @@
|
||||
// Legt einen Admin-Zugang für einen bestehenden Mitarbeiter an oder setzt ihn zurück.
|
||||
//
|
||||
// Ohne --password: Konto wird (neu) angelegt, das Passwort wird beim nächsten
|
||||
// Login vom Nutzer selbst vergeben (gleicher Mechanismus wie bei der Benutzerverwaltung).
|
||||
// Mit --password: setzt sofort ein Passwort (Notfall-Wiederherstellung, falls der
|
||||
// Admin sich ausgesperrt hat).
|
||||
//
|
||||
// Aufruf:
|
||||
// node scripts/reset-admin.js --employee-id 3
|
||||
// node scripts/reset-admin.js --email admin@example.com --password neuesPasswort
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
|
||||
function readArg(name) {
|
||||
const idx = process.argv.indexOf(`--${name}`);
|
||||
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
||||
}
|
||||
|
||||
const employeeId = readArg('employee-id');
|
||||
let email = readArg('email');
|
||||
const password = readArg('password');
|
||||
|
||||
if (!employeeId && !email) {
|
||||
console.error(
|
||||
'Nutzung: node scripts/reset-admin.js --employee-id <id> [--password <passwort>]\n' +
|
||||
' oder node scripts/reset-admin.js --email <email> [--password <passwort>]'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (employeeId && !email) {
|
||||
const employee = db.prepare('SELECT email FROM employees WHERE id = ?').get(employeeId);
|
||||
if (!employee || !employee.email) {
|
||||
console.error(`Mitarbeiter ${employeeId} wurde nicht gefunden oder hat keine E-Mail hinterlegt.`);
|
||||
process.exit(1);
|
||||
}
|
||||
email = employee.email;
|
||||
}
|
||||
|
||||
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, ?, 'admin')
|
||||
ON CONFLICT (email) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash`
|
||||
).run(employeeId || null, email, passwordHash);
|
||||
|
||||
console.log(
|
||||
password
|
||||
? `Admin-Zugang für ${email} wurde mit dem angegebenen Passwort gesetzt.`
|
||||
: `Admin-Zugang für ${email} wurde angelegt/zurückgesetzt. Passwort wird beim nächsten Login vergeben.`
|
||||
);
|
||||
Reference in New Issue
Block a user