This commit is contained in:
2026-07-02 22:13:33 +02:00
parent b343ad8a38
commit ba6c35f76d
30 changed files with 2752 additions and 2 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "jahresplanung-backend",
"version": "1.0.0",
"private": true,
"description": "API und Static-Server fuer die Jahresplanung-App",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.19.2",
"cors": "^2.8.5",
"mongodb": "^6.3.0"
},
"engines": {
"node": ">=18"
}
}
+72
View File
@@ -0,0 +1,72 @@
// Einmaliges Migrationsskript: liest eine alte data.json (aus der
// JSON-Datei-Version der App) und schreibt die Inhalte nach MongoDB.
//
// Nutzung:
// MONGODB_URI=mongodb://user:pass@host:27017 \
// node scripts/migrate-json-to-mongo.js /pfad/zu/data.json
//
// Das Skript ist idempotent-freundlich: bestehende Dokumente mit gleicher
// _id werden ueberschrieben (upsert), es entstehen keine Duplikate bei
// mehrfacher Ausfuehrung.
const fs = require("fs");
const { MongoClient } = require("mongodb");
async function main() {
const filePath = process.argv[2];
if (!filePath) {
console.error("Bitte Pfad zur data.json angeben.");
console.error("Beispiel: node scripts/migrate-json-to-mongo.js ./data.json");
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.error(`Datei nicht gefunden: ${filePath}`);
process.exit(1);
}
const raw = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(raw);
const uri = process.env.MONGODB_URI || "mongodb://localhost:27017";
const dbName = process.env.MONGODB_DB || "jahresplanung";
const client = new MongoClient(uri);
await client.connect();
const db = client.db(dbName);
const collections = {
people: data.people || [],
projects: data.projects || [],
tasks: data.tasks || [],
absences: data.absences || []
};
for (const [name, items] of Object.entries(collections)) {
if (items.length === 0) {
console.log(`- ${name}: keine Eintraege, uebersprungen`);
continue;
}
const ops = items.map((item) => {
const { id, ...rest } = item; // "id" -> "_id" umbenennen
return {
replaceOne: {
filter: { _id: id },
replacement: { _id: id, ...rest },
upsert: true
}
};
});
const result = await db.collection(name).bulkWrite(ops);
console.log(
`- ${name}: ${result.upsertedCount} neu, ${result.modifiedCount} aktualisiert`
);
}
await client.close();
console.log("Migration abgeschlossen.");
}
main().catch((err) => {
console.error("Migration fehlgeschlagen:", err);
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
const { MongoClient, ObjectId } = require("mongodb");
const uri = process.env.MONGODB_URI || "mongodb://localhost:27017/jahresplanung";
let client;
let dbPromise;
// Stellt sicher, dass nur eine Verbindung aufgebaut wird und wiederverwendet wird.
function connect() {
if (!dbPromise) {
client = new MongoClient(uri);
dbPromise = client
.connect()
.then((c) => c.db())
.catch((err) => {
dbPromise = null; // Bei Fehler erneuten Verbindungsversuch beim naechsten Aufruf erlauben
throw err;
});
}
return dbPromise;
}
// Wandelt eine String-ID in eine ObjectId um, oder liefert null bei ungueltigem Format.
function toId(idStr) {
if (!idStr || !ObjectId.isValid(idStr)) return null;
return new ObjectId(idStr);
}
// Wandelt ein Mongo-Dokument (_id) in die vom Frontend erwartete Form (id) um.
function serialize(doc) {
if (!doc) return null;
const { _id, ...rest } = doc;
return { id: _id.toString(), ...rest };
}
module.exports = { connect, toId, serialize, ObjectId };
+19
View File
@@ -0,0 +1,19 @@
const crypto = require("crypto");
function newId() {
return crypto.randomUUID();
}
// Wir speichern unsere eigene UUID direkt als Mongo _id, damit das Frontend
// weiterhin ein einfaches "id"-Feld sieht und keine ObjectId-Logik braucht.
function toClient(doc) {
if (!doc) return null;
const { _id, ...rest } = doc;
return { id: _id, ...rest };
}
function toClientList(docs) {
return docs.map(toClient);
}
module.exports = { newId, toClient, toClientList };
+45
View File
@@ -0,0 +1,45 @@
const { MongoClient } = require("mongodb");
const uri = process.env.MONGODB_URI || "mongodb://localhost:27017";
const dbName = process.env.MONGODB_DB || "jahresplanung";
let client;
let db;
async function connect(retries = 10, delayMs = 2000) {
if (db) return db;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 });
await client.connect();
db = client.db(dbName);
await db.collection("tasks").createIndex({ projectId: 1 });
await db.collection("tasks").createIndex({ assigneeId: 1 });
await db.collection("absences").createIndex({ personId: 1 });
console.log(`Verbunden mit MongoDB-Datenbank "${dbName}"`);
return db;
} catch (err) {
console.warn(
`MongoDB-Verbindungsversuch ${attempt}/${retries} fehlgeschlagen: ${err.message}`
);
if (attempt === retries) throw err;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
function getDb() {
if (!db) {
throw new Error("MongoDB ist noch nicht verbunden. connect() muss zuerst aufgerufen werden.");
}
return db;
}
async function close() {
if (client) await client.close();
}
module.exports = { connect, getDb, close };
+53
View File
@@ -0,0 +1,53 @@
const path = require("path");
const express = require("express");
const cors = require("cors");
const { connect } = require("./db");
const peopleRouter = require("./routes/people");
const projectsRouter = require("./routes/projects");
const tasksRouter = require("./routes/tasks");
const absencesRouter = require("./routes/absences");
const capacityRouter = require("./routes/capacity");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get("/api/health", (req, res) => res.json({ status: "ok" }));
app.use("/api/people", peopleRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/absences", absencesRouter);
app.use("/api/capacity", capacityRouter);
// Gebautes Frontend (Vite -> dist) ausliefern
const frontendDist = path.join(__dirname, "..", "public");
app.use(express.static(frontendDist));
// SPA-Fallback: alles, was nicht /api ist, liefert die index.html aus
app.get(/^(?!\/api).*/, (req, res) => {
res.sendFile(path.join(frontendDist, "index.html"));
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Interner Serverfehler" });
});
async function start() {
try {
await connect();
app.listen(PORT, () => {
console.log(`Jahresplanung-Server laeuft auf Port ${PORT}`);
});
} catch (err) {
console.error("Konnte keine Verbindung zu MongoDB herstellen:", err);
process.exit(1);
}
}
start();
+40
View File
@@ -0,0 +1,40 @@
const express = require("express");
const { connect, toId, serialize } = require("../db");
const router = express.Router();
router.get("/", async (req, res) => {
const db = await connect();
const absences = await db.collection("absences").find().sort({ startDate: 1 }).toArray();
res.json(absences.map(serialize));
});
router.post("/", async (req, res) => {
const { personId, startDate, endDate, note } = req.body;
if (!personId || !startDate || !endDate) {
return res
.status(400)
.json({ error: "personId, startDate und endDate sind erforderlich" });
}
const db = await connect();
const personObjectId = toId(personId);
if (!personObjectId || !(await db.collection("people").findOne({ _id: personObjectId }))) {
return res.status(400).json({ error: "Unbekannte Person" });
}
const doc = { personId, startDate, endDate, note: note || "" };
const result = await db.collection("absences").insertOne(doc);
res.status(201).json(serialize({ _id: result.insertedId, ...doc }));
});
router.delete("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Abwesenheit nicht gefunden" });
const db = await connect();
const result = await db.collection("absences").deleteOne({ _id });
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Abwesenheit nicht gefunden" });
}
res.status(204).end();
});
module.exports = router;
+86
View File
@@ -0,0 +1,86 @@
const express = require("express");
const { connect } = require("../db");
const {
countWorkdays,
overlapWorkdays,
monthsInRange,
monthBounds
} = require("../utils/dateUtils");
const router = express.Router();
// GET /api/capacity?from=YYYY-MM-DD&to=YYYY-MM-DD
// Liefert pro Person und Monat: Kapazitaet (Stunden), zugewiesene Stunden,
// Auslastung in Prozent und den Ueberlast-Status.
router.get("/", async (req, res) => {
const db = await connect();
const from = req.query.from || `${new Date().getFullYear()}-01-01`;
const to = req.query.to || `${new Date().getFullYear()}-12-31`;
const months = monthsInRange(from, to);
const [people, absences, tasks] = await Promise.all([
db.collection("people").find().sort({ name: 1 }).toArray(),
db.collection("absences").find().toArray(),
db.collection("tasks").find().toArray()
]);
const result = people.map((person) => {
const personId = person._id.toString();
const dailyHours = person.weeklyHours / 5;
const personAbsences = absences.filter((a) => a.personId === personId);
const personTasks = tasks.filter((t) => t.assigneeId === personId);
const monthsData = months.map((month) => {
const { start: mStart, end: mEnd } = monthBounds(month);
const workdaysInMonth = countWorkdays(mStart, mEnd);
let absenceWorkdays = 0;
personAbsences.forEach((a) => {
absenceWorkdays += overlapWorkdays(a.startDate, a.endDate, mStart, mEnd);
});
absenceWorkdays = Math.min(absenceWorkdays, workdaysInMonth);
const capacityHours = Math.max(
0,
(workdaysInMonth - absenceWorkdays) * dailyHours
);
let assignedHours = 0;
personTasks.forEach((task) => {
const taskWorkdays = countWorkdays(task.startDate, task.endDate);
if (taskWorkdays === 0) return;
const dailyRate = task.estimatedHours / taskWorkdays;
const overlap = overlapWorkdays(task.startDate, task.endDate, mStart, mEnd);
assignedHours += dailyRate * overlap;
});
const utilization =
capacityHours > 0
? Math.round((assignedHours / capacityHours) * 100)
: assignedHours > 0
? 999
: 0;
return {
month,
capacityHours: Math.round(capacityHours * 10) / 10,
assignedHours: Math.round(assignedHours * 10) / 10,
utilization,
overloaded: assignedHours > capacityHours + 0.5
};
});
return {
personId,
name: person.name,
color: person.color,
weeklyHours: person.weeklyHours,
months: monthsData
};
});
res.json({ from, to, months, people: result });
});
module.exports = router;
+68
View File
@@ -0,0 +1,68 @@
const express = require("express");
const { connect, toId, serialize } = require("../db");
const router = express.Router();
router.get("/", async (req, res) => {
const db = await connect();
const people = await db.collection("people").find().sort({ name: 1 }).toArray();
res.json(people.map(serialize));
});
router.post("/", async (req, res) => {
const { name, weeklyHours, color, role } = req.body;
if (!name || typeof name !== "string") {
return res.status(400).json({ error: "name ist erforderlich" });
}
const db = await connect();
const doc = {
name,
role: role || "",
weeklyHours: Number(weeklyHours) || 40,
color: color || "#2F6F4F"
};
const result = await db.collection("people").insertOne(doc);
res.status(201).json(serialize({ _id: result.insertedId, ...doc }));
});
router.put("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Person nicht gefunden" });
const { name, weeklyHours, color, role } = req.body;
const update = {};
if (name !== undefined) update.name = name;
if (role !== undefined) update.role = role;
if (weeklyHours !== undefined) update.weeklyHours = Number(weeklyHours);
if (color !== undefined) update.color = color;
const db = await connect();
const updated = await db
.collection("people")
.findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" });
if (!updated) return res.status(404).json({ error: "Person nicht gefunden" });
res.json(serialize(updated));
});
router.delete("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Person nicht gefunden" });
const db = await connect();
const result = await db.collection("people").deleteOne({ _id });
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Person nicht gefunden" });
}
const idStr = req.params.id;
// Zuweisungen und Abwesenheiten der geloeschten Person aufraeumen
await db
.collection("tasks")
.updateMany({ assigneeId: idStr }, { $set: { assigneeId: null } });
await db.collection("absences").deleteMany({ personId: idStr });
res.status(204).end();
});
module.exports = router;
+59
View File
@@ -0,0 +1,59 @@
const express = require("express");
const { connect, toId, serialize } = require("../db");
const router = express.Router();
router.get("/", async (req, res) => {
const db = await connect();
const projects = await db.collection("projects").find().sort({ name: 1 }).toArray();
res.json(projects.map(serialize));
});
router.post("/", async (req, res) => {
const { name, description, color } = req.body;
if (!name || typeof name !== "string") {
return res.status(400).json({ error: "name ist erforderlich" });
}
const db = await connect();
const doc = {
name,
description: description || "",
color: color || "#3E5C50"
};
const result = await db.collection("projects").insertOne(doc);
res.status(201).json(serialize({ _id: result.insertedId, ...doc }));
});
router.put("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Projekt nicht gefunden" });
const { name, description, color } = req.body;
const update = {};
if (name !== undefined) update.name = name;
if (description !== undefined) update.description = description;
if (color !== undefined) update.color = color;
const db = await connect();
const updated = await db
.collection("projects")
.findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" });
if (!updated) return res.status(404).json({ error: "Projekt nicht gefunden" });
res.json(serialize(updated));
});
router.delete("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Projekt nicht gefunden" });
const db = await connect();
const result = await db.collection("projects").deleteOne({ _id });
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Projekt nicht gefunden" });
}
await db.collection("tasks").deleteMany({ projectId: req.params.id });
res.status(204).end();
});
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const express = require("express");
const { connect, toId, serialize } = require("../db");
const router = express.Router();
router.get("/", async (req, res) => {
const db = await connect();
const tasks = await db.collection("tasks").find().sort({ startDate: 1 }).toArray();
res.json(tasks.map(serialize));
});
router.post("/", async (req, res) => {
const {
projectId,
name,
startDate,
endDate,
estimatedHours,
assigneeId,
status,
priority,
dependsOn
} = req.body;
if (!projectId || !name || !startDate || !endDate) {
return res
.status(400)
.json({ error: "projectId, name, startDate und endDate sind erforderlich" });
}
if (startDate > endDate) {
return res.status(400).json({ error: "startDate muss vor endDate liegen" });
}
const db = await connect();
const projectObjectId = toId(projectId);
if (!projectObjectId || !(await db.collection("projects").findOne({ _id: projectObjectId }))) {
return res.status(400).json({ error: "Unbekanntes Projekt" });
}
const doc = {
projectId,
name,
startDate,
endDate,
estimatedHours: Number(estimatedHours) || 0,
assigneeId: assigneeId || null,
status: status || "geplant",
priority: priority || "mittel",
dependsOn: dependsOn || null
};
const result = await db.collection("tasks").insertOne(doc);
res.status(201).json(serialize({ _id: result.insertedId, ...doc }));
});
router.put("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Aufgabe nicht gefunden" });
const db = await connect();
const existing = await db.collection("tasks").findOne({ _id });
if (!existing) return res.status(404).json({ error: "Aufgabe nicht gefunden" });
const fields = [
"projectId",
"name",
"startDate",
"endDate",
"estimatedHours",
"assigneeId",
"status",
"priority",
"dependsOn"
];
const update = {};
fields.forEach((f) => {
if (req.body[f] !== undefined) {
update[f] = f === "estimatedHours" ? Number(req.body[f]) : req.body[f];
}
});
const merged = { ...existing, ...update };
if (merged.startDate > merged.endDate) {
return res.status(400).json({ error: "startDate muss vor endDate liegen" });
}
const updated = await db
.collection("tasks")
.findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" });
res.json(serialize(updated));
});
router.delete("/:id", async (req, res) => {
const _id = toId(req.params.id);
if (!_id) return res.status(404).json({ error: "Aufgabe nicht gefunden" });
const db = await connect();
const result = await db.collection("tasks").deleteOne({ _id });
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Aufgabe nicht gefunden" });
}
// Abhaengigkeiten auf geloeschte Aufgabe aufraeumen
await db
.collection("tasks")
.updateMany({ dependsOn: req.params.id }, { $set: { dependsOn: null } });
res.status(204).end();
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
// Alle Datumswerte werden als "YYYY-MM-DD" Strings gehandhabt und als UTC
// Datum interpretiert, damit Zeitzonen keine Off-by-one Fehler verursachen.
function parseDate(str) {
const [y, m, d] = str.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
function formatDate(date) {
return date.toISOString().slice(0, 10);
}
function addDays(date, days) {
const d = new Date(date);
d.setUTCDate(d.getUTCDate() + days);
return d;
}
function isWeekday(date) {
const day = date.getUTCDay();
return day !== 0 && day !== 6;
}
// Anzahl Werktage (Mo-Fr) zwischen start und end (inklusive beider Enden)
function countWorkdays(startStr, endStr) {
let start = parseDate(startStr);
let end = parseDate(endStr);
if (start > end) return 0;
let count = 0;
let cur = start;
while (cur <= end) {
if (isWeekday(cur)) count++;
cur = addDays(cur, 1);
}
return count;
}
// Ueberschneidung zweier Datumsbereiche in Werktagen
function overlapWorkdays(aStart, aEnd, bStart, bEnd) {
const start = parseDate(aStart) > parseDate(bStart) ? aStart : bStart;
const end = parseDate(aEnd) < parseDate(bEnd) ? aEnd : bEnd;
if (parseDate(start) > parseDate(end)) return 0;
return countWorkdays(start, end);
}
// Liste von Monaten (YYYY-MM) zwischen start und end (inklusive)
function monthsInRange(startStr, endStr) {
const start = parseDate(startStr);
const end = parseDate(endStr);
const months = [];
let cur = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1));
const last = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1));
while (cur <= last) {
const y = cur.getUTCFullYear();
const m = String(cur.getUTCMonth() + 1).padStart(2, "0");
months.push(`${y}-${m}`);
cur = new Date(Date.UTC(y, cur.getUTCMonth() + 1, 1));
}
return months;
}
function monthBounds(monthStr) {
const [y, m] = monthStr.split("-").map(Number);
const start = new Date(Date.UTC(y, m - 1, 1));
const end = new Date(Date.UTC(y, m, 0));
return { start: formatDate(start), end: formatDate(end) };
}
module.exports = {
parseDate,
formatDate,
addDays,
isWeekday,
countWorkdays,
overlapWorkdays,
monthsInRange,
monthBounds
};