Files
ferdi2goandClaude Fable 5.1 42d495c54e Tauschanfragen gegen offene Plätze erlauben und Lücken nachbesetzen
Ein Wunschtag ohne Tauschpartner bedeutet jetzt: die Person übernimmt
dort einen offenen Platz; ihr alter Platz wird beim Ausführen sofort
fair nachbesetzt, ohne sie selbst. Rollen werden bei allen Tauschformen
geprüft. Bearbeiter können Person und Wunschtag einer Anfrage nachtragen,
etwa aus der Notiz. Der Anfragedialog zeigt, was am Wunschtag los ist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:55:53 +00:00

912 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express';
import path from 'node:path';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadEnvFile } from './env.js';
import { createStore, imageMime } from './store.js';
import { looksLikePlaceholder } from './image.js';
import { createAuth, readCookie, cookieHeader } from './auth.js';
import { validateRequest, applySwap, REQUEST_STATUS } from './requests.js';
import { parseScreenshot, buildPrompt, BUG_STATUS, DEFAULT_PROMPT_TEMPLATE } from './bugs.js';
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
import { nextcloudConfigFromEnv, createNextcloudClient } from './nextcloud.js';
import { parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences } from './ical.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
loadEnvFile(path.join(__dirname, '..', '.env'));
const dataDir = process.env.VCH_DATA_DIR || path.join(__dirname, '..', 'data');
const store = createStore(dataDir);
const nc = nextcloudConfigFromEnv();
if (String(process.env.NEXTCLOUD_INSECURE_TLS).toLowerCase() === 'true') {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
console.warn('Warnung: TLS-Zertifikate werden nicht geprüft (NEXTCLOUD_INSECURE_TLS=true).');
}
// Versionskennung für Fehlermeldungen: Commit aus Umgebung oder Git, sonst unbekannt
let appVersion = (process.env.VCH_COMMIT || process.env.APP_VERSION || '').trim();
if (!appVersion) {
try { appVersion = execSync('git rev-parse --short HEAD', { cwd: path.join(__dirname, '..'), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { appVersion = 'unbekannt'; }
}
export const app = express();
app.disable('x-powered-by');
app.set('etag', false);
// Pfade nur in exakter Schreibweise zuordnen – sonst liefe /API/… an der Bearbeitungssperre vorbei
app.set('case sensitive routing', true);
// Welche Proxys dürfen X-Forwarded-For setzen? Leer = nur localhost, "none" = keiner, sonst Adressen/Netze
const trustedProxy = (process.env.TRUSTED_PROXY || '').trim();
app.set('trust proxy', trustedProxy === '' ? 'loopback' : trustedProxy === 'none' ? false : trustedProxy.split(',').map((x) => x.trim()));
const clientIp = (req) => req.ip || req.socket?.remoteAddress || '?';
// Healthcheck ohne Passwort, damit die Plattform den Prüfling erreichen kann
app.get('/health', (req, res) => res.type('text/plain').send('ok'));
// Optionaler Zugriffsschutz
const appPassword = (process.env.APP_PASSWORD || '').trim();
if (appPassword) {
app.use((req, res, next) => {
const header = req.headers.authorization || '';
const given = header.startsWith('Basic ') ? Buffer.from(header.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':') : '';
const a = Buffer.from(given);
const b = Buffer.from(appPassword);
if (a.length === b.length && timingSafeEqual(a, b)) return next();
res.set('WWW-Authenticate', 'Basic realm="Rotatory", charset="UTF-8"');
res.status(401).send('Zugang nur mit Passwort.');
});
}
const jsonBody = express.json({ limit: '1mb' });
const bigJsonBody = express.json({ limit: '9mb' }); // Fehlermeldung mit Screenshot (6 MB Bild als Base64)
// Bremse für Fehlermeldungen: jeder Versuch zählt, auch ein später abgelehnter – und zwar bevor der große Body gelesen wird
const bugHits = new Map(); // ip → [{ t, bytes }]
const BUG_WINDOW_MS = 3_600_000;
const BUG_MAX_PER_HOUR = 10;
const BUG_MAX_BYTES_PER_HOUR = 30 * 1024 * 1024;
function bugLimited(req) {
const ip = clientIp(req);
const now = Date.now();
const hits = (bugHits.get(ip) || []).filter((h) => now - h.t < BUG_WINDOW_MS);
const declared = Number(req.headers['content-length']) || 0;
const bytes = hits.reduce((n, h) => n + h.bytes, 0);
bugHits.set(ip, hits);
if (hits.length >= BUG_MAX_PER_HOUR || bytes + declared > BUG_MAX_BYTES_PER_HOUR) return true;
hits.push({ t: now, bytes: declared });
return false;
}
app.use((req, res, next) => {
if (req.method !== 'POST' || req.path !== '/api/bugs') return jsonBody(req, res, next);
if (bugLimited(req)) return res.status(429).json({ error: 'Zu viele Meldungen in kurzer Zeit. Bitte später noch einmal.' });
return bigJsonBody(req, res, next);
});
app.use(express.static(path.join(__dirname, '..', 'public')));
// ---------- Bearbeitungsschutz ----------
// Ohne EDIT_PASSWORD darf jeder alles (wie bisher). Mit Passwort sehen Betrachter nur den Kalender.
const auth = createAuth({ password: process.env.EDIT_PASSWORD, secretFile: path.join(dataDir, 'session-secret') });
const isSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
app.use((req, res, next) => {
req.isEditor = !auth.enabled || auth.verify(readCookie(req.headers.cookie));
next();
});
// Alles unter /api ist für Bearbeiter – außer dieser ausdrücklichen Liste
const PUBLIC_API = (method, p) => p === '/api/auth' || p.startsWith('/api/auth/')
|| (method === 'POST' && (p === '/api/requests' || p === '/api/bugs')) // Tauschanfragen und Fehlermeldungen darf jeder stellen
|| ((method === 'GET' || method === 'HEAD') && (p === '/api/state' || p === '/api/stats' || p === '/api/export.ics'));
const needsEditor = (req) => req.path.startsWith('/api/') && !PUBLIC_API(req.method, req.path);
app.use((req, res, next) => {
if (needsEditor(req) && !req.isEditor) return res.status(403).json({ error: 'Anmeldung erforderlich.', authRequired: true });
next();
});
app.get('/api/auth', (req, res) => res.json({ protected: auth.enabled, editor: req.isEditor }));
app.post('/api/auth/login', (req, res) => {
if (!auth.enabled) return res.json({ protected: false, editor: true });
const ip = clientIp(req);
const wait = auth.blockedFor(ip);
if (wait > 0) return res.status(429).json({ error: `Zu viele Fehlversuche. Bitte ${wait} Sekunden warten.` });
if (!auth.checkPassword(req.body?.password)) {
auth.noteFailure(ip);
return res.status(401).json({ error: 'Passwort stimmt nicht.' });
}
auth.noteSuccess(ip);
res.set('Set-Cookie', cookieHeader(auth.issue(), { secure: isSecure(req) }));
res.json({ protected: true, editor: true });
});
app.post('/api/auth/logout', (req, res) => {
res.set('Set-Cookie', cookieHeader('', { secure: isSecure(req), maxAgeMs: 0 }));
res.json({ protected: auth.enabled, editor: !auth.enabled });
});
const S = () => store.state;
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
const bad = (res, msg) => res.status(400).json({ error: msg });
const blockedDays = () => new Set(S().holidays.map((h) => h.date));
const ABSENCE_TYPES = ['urlaub', 'krankheit', 'frei', 'sonstiges'];
// Beim ersten Start: Feiertage für dieses und nächstes Jahr vorbelegen
if (S().holidays.length === 0 && !S().settings.holidaysSeeded) {
const year = new Date().getFullYear();
for (const h of [...germanHolidays(year, S().settings.bundesland), ...germanHolidays(year + 1, S().settings.bundesland)]) S().holidays.push(h);
S().settings.holidaysSeeded = true;
store.save();
}
/** Gehört eine Kalender-/Adressbuch-URL zur konfigurierten Nextcloud (gleicher Origin, gleicher Pfadstamm)? */
function isOurNextcloudUrl(href) {
if (!nc.baseUrl || !href) return false;
try {
const a = new URL(href);
const b = new URL(nc.baseUrl + '/');
return a.origin === b.origin && a.pathname.startsWith(b.pathname);
} catch {
return false;
}
}
function personsForScheduling() {
return S().persons.map((p) => ({ id: p.id, canMain: Boolean(p.canMain), canCo: Boolean(p.canCo), active: p.active !== false, freeDays: p.freeDays || [] }));
}
function personLabel(p) {
return p ? `${p.firstName} ${p.lastName}`.trim() : 'unbesetzt';
}
function publicPerson(p) {
return { ...p, hasPhoto: store.hasPhoto(p.id) };
}
// ---------- Gesamtzustand ----------
app.get('/api/state', (req, res) => {
const st = S();
const authInfo = { protected: auth.enabled, editor: req.isEditor };
if (!req.isEditor) {
// Betrachter: nur, was der Kalender braucht – keine E-Mails, Notizen, Abwesenheiten oder Zugangsdetails
return res.json({
auth: authInfo,
persons: st.persons.map((p) => ({ id: p.id, firstName: p.firstName, lastName: p.lastName, canMain: p.canMain, canCo: p.canCo, active: p.active, photoVersion: p.photoVersion, hasPhoto: store.hasPhoto(p.id) })),
absences: [],
holidays: st.holidays,
assignments: st.assignments,
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
exported: {},
version: appVersion,
settings: { meetingTime: st.settings.meetingTime, durationMinutes: st.settings.durationMinutes },
bundeslaender: BUNDESLAENDER,
absenceTypes: ABSENCE_TYPES,
nextcloud: { configured: false },
});
}
res.json({
auth: authInfo,
persons: st.persons.map(publicPerson),
absences: st.absences,
holidays: st.holidays,
assignments: st.assignments,
requests: st.requests,
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
bugs: st.bugs,
version: appVersion,
exported: st.exported,
settings: st.settings,
bundeslaender: BUNDESLAENDER,
absenceTypes: ABSENCE_TYPES,
nextcloud: { configured: nc.configured, calendar: nc.calendar, user: nc.user, baseUrl: nc.baseUrl },
});
});
// ---------- Personen ----------
function readPerson(body, existing = {}) {
const firstName = clean(body.firstName);
const lastName = clean(body.lastName);
if (!firstName || !lastName) return { error: 'Vor- und Nachname sind Pflicht.' };
const email = clean(body.email).toLowerCase();
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'E-Mail-Adresse ist ungültig.' };
let freeDays = existing.freeDays || [];
if (body.freeDays !== undefined) {
if (!Array.isArray(body.freeDays)) return { error: 'freeDays muss eine Liste sein.' };
freeDays = [...new Set(body.freeDays.map(Number).filter((d) => Number.isInteger(d) && d >= 1 && d <= 5))].sort();
}
return {
person: {
...existing,
firstName,
lastName,
email,
canMain: body.canMain !== undefined ? Boolean(body.canMain) : existing.canMain ?? true,
canCo: body.canCo !== undefined ? Boolean(body.canCo) : existing.canCo ?? true,
active: body.active !== undefined ? Boolean(body.active) : existing.active ?? true,
freeDays,
note: body.note !== undefined ? clean(body.note, 300) : existing.note || '',
},
};
}
app.post('/api/persons', (req, res) => {
const r = readPerson(req.body || {});
if (r.error) return bad(res, r.error);
const person = { id: randomUUID(), ...r.person, photoVersion: 0 };
S().persons.push(person);
store.save();
res.status(201).json(publicPerson(person));
});
app.put('/api/persons/:id', (req, res) => {
const p = S().persons.find((x) => x.id === req.params.id);
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
const r = readPerson(req.body || {}, p);
if (r.error) return bad(res, r.error);
Object.assign(p, r.person);
store.save();
res.json(publicPerson(p));
});
app.delete('/api/persons/:id', (req, res) => {
const st = S();
const idx = st.persons.findIndex((x) => x.id === req.params.id);
if (idx < 0) return res.status(404).json({ error: 'Person nicht gefunden.' });
const id = st.persons[idx].id;
st.persons.splice(idx, 1);
st.absences = st.absences.filter((a) => a.personId !== id);
for (const a of Object.values(st.assignments)) {
if (a.main === id) a.main = null;
if (a.co === id) a.co = null;
}
store.deletePhoto(id);
store.save();
res.status(204).end();
});
app.put('/api/persons/:id/photo', express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '5mb' }), (req, res) => {
const p = S().persons.find((x) => x.id === req.params.id);
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
if (!Buffer.isBuffer(req.body) || req.body.length === 0 || !imageMime(req.body)) return bad(res, 'Kein gültiges Bild übermittelt (erlaubt: JPEG, PNG, WebP).');
store.savePhoto(p.id, req.body);
p.photoVersion = (p.photoVersion || 0) + 1;
store.save();
res.json(publicPerson(p));
});
app.delete('/api/persons/:id/photo', (req, res) => {
const p = S().persons.find((x) => x.id === req.params.id);
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
store.deletePhoto(p.id);
p.photoVersion = (p.photoVersion || 0) + 1;
store.save();
res.status(204).end();
});
/** Bereits gespeicherte Fotos, die wie Initialen-Platzhalter aussehen. */
function placeholderPhotoIds() {
return S().persons.filter((p) => { const buf = store.readPhoto(p.id); return buf && looksLikePlaceholder(buf); }).map((p) => p.id);
}
app.get('/api/persons/placeholder-photos', (req, res) => {
res.json({ ids: placeholderPhotoIds() });
});
app.post('/api/persons/placeholder-photos/remove', (req, res) => {
const ids = placeholderPhotoIds();
for (const id of ids) {
const p = S().persons.find((x) => x.id === id);
store.deletePhoto(id);
if (p) p.photoVersion = (p.photoVersion || 0) + 1;
}
store.save();
res.json({ removed: ids.length, persons: S().persons.map(publicPerson) });
});
app.get('/photos/:id', (req, res) => {
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
const buf = store.readPhoto(id);
if (!buf) return res.status(404).end();
res.set('Cache-Control', 'public, max-age=86400');
res.type(imageMime(buf) || 'application/octet-stream');
res.send(buf);
});
// ---------- Abwesenheiten ----------
app.post('/api/absences', (req, res) => {
const { personId, from, to, type = 'urlaub', note } = req.body || {};
if (!S().persons.some((p) => p.id === personId)) return bad(res, 'Person nicht gefunden.');
if (!isValidDate(from) || !isValidDate(to)) return bad(res, 'Ungültiges Datum.');
if (to < from) return bad(res, 'Das Ende liegt vor dem Beginn.');
if (!ABSENCE_TYPES.includes(type)) return bad(res, 'Unbekannte Abwesenheitsart.');
const absence = { id: randomUUID(), personId, from, to, type, note: clean(note, 200), source: 'manuell' };
S().absences.push(absence);
store.save();
res.status(201).json(absence);
});
app.delete('/api/absences/:id', (req, res) => {
const st = S();
const before = st.absences.length;
st.absences = st.absences.filter((a) => a.id !== req.params.id);
if (st.absences.length === before) return res.status(404).json({ error: 'Abwesenheit nicht gefunden.' });
store.save();
res.status(204).end();
});
// ---------- Feiertage / betriebsfreie Tage ----------
function upsertHoliday(h) {
const st = S();
st.holidays = st.holidays.filter((x) => x.date !== h.date);
st.holidays.push(h);
st.holidays.sort((a, b) => a.date.localeCompare(b.date));
}
app.post('/api/holidays', (req, res) => {
const { date, name } = req.body || {};
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
upsertHoliday({ date, name: clean(name) || 'Kein Meeting', auto: false });
store.save();
res.status(201).json(S().holidays);
});
app.post('/api/holidays/generate', (req, res) => {
const year = Number(req.body?.year);
if (!Number.isInteger(year) || year < 2000 || year > 2100) return bad(res, 'Ungültiges Jahr.');
for (const h of germanHolidays(year, S().settings.bundesland)) upsertHoliday(h);
store.save();
res.json(S().holidays);
});
app.delete('/api/holidays/:date', (req, res) => {
const st = S();
st.holidays = st.holidays.filter((h) => h.date !== req.params.date);
store.save();
res.status(204).end();
});
// ---------- Einstellungen ----------
app.put('/api/settings', (req, res) => {
const s = S().settings;
const b = req.body || {};
if (b.meetingTime !== undefined) {
if (b.meetingTime !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(b.meetingTime)) return bad(res, 'Uhrzeit im Format HH:MM angeben (oder leer für ganztägig).');
s.meetingTime = b.meetingTime;
}
if (b.durationMinutes !== undefined) {
const d = Number(b.durationMinutes);
if (!Number.isInteger(d) || d < 5 || d > 480) return bad(res, 'Dauer zwischen 5 und 480 Minuten.');
s.durationMinutes = d;
}
if (b.bundesland !== undefined) {
if (!Object.hasOwn(BUNDESLAENDER, b.bundesland)) return bad(res, 'Unbekanntes Bundesland.');
s.bundesland = b.bundesland;
}
if (b.calendarDisplayName !== undefined) s.calendarDisplayName = clean(b.calendarDisplayName) || 'Morning Meeting';
if (b.inviteAttendees !== undefined) s.inviteAttendees = Boolean(b.inviteAttendees);
if (b.historyWindowDays !== undefined) {
const d = Number(b.historyWindowDays);
if (!Number.isInteger(d) || d < 0 || d > 365) return bad(res, 'Fairness-Fenster zwischen 0 und 365 Tagen.');
s.historyWindowDays = d;
}
if (b.bugPromptTemplate !== undefined) s.bugPromptTemplate = clean(b.bugPromptTemplate, 4000) || DEFAULT_PROMPT_TEMPLATE;
for (const key of ['targetCalendarHref', 'absenceCalendarHref']) {
if (b[key] === undefined) continue;
const href = clean(b[key], 500);
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
s[key] = href;
const nameKey = key.replace('Href', 'Name');
s[nameKey] = href ? clean(b[nameKey], 120) : '';
}
store.save();
res.json(s);
});
// ---------- Planung ----------
function parseRange(body) {
const { from, to } = body || {};
if (!isValidDate(from) || !isValidDate(to)) return { error: 'Ungültiger Zeitraum.' };
if (to < from) return { error: 'Das Ende liegt vor dem Beginn.' };
if (addDays(from, 400) < to) return { error: 'Höchstens 400 Tage auf einmal.' };
return { from, to };
}
app.post('/api/schedule/generate', (req, res) => {
const r = parseRange(req.body);
if (r.error) return bad(res, r.error);
const overwrite = Boolean(req.body.overwrite);
const st = S();
const dates = workingDays(r.from, r.to, blockedDays());
const existing = {};
for (const d of dates) {
const ex = st.assignments[d];
if (!ex) continue;
// Ohne „überschreiben": volle Tage behalten, halb besetzte nur auffüllen
existing[d] = overwrite ? { ...ex } : { ...ex, keep: Boolean(ex.main && ex.co), fill: Boolean(ex.main || ex.co) };
}
const generated = generateSchedule({
dates,
persons: personsForScheduling(),
absences: st.absences,
existing,
history: st.assignments,
blocked: blockedDays(),
historyWindowDays: st.settings.historyWindowDays,
});
Object.assign(st.assignments, generated);
for (const d of Object.keys(st.assignments)) {
if (d >= r.from && d <= r.to && !dates.includes(d) && !st.assignments[d].off) delete st.assignments[d];
}
store.save();
const unfilled = dates.filter((d) => !st.assignments[d].off && (!st.assignments[d].main || !st.assignments[d].co));
res.json({ assignments: st.assignments, planned: dates.length, unfilled });
});
app.post('/api/schedule/clear', (req, res) => {
const r = parseRange(req.body);
if (r.error) return bad(res, r.error);
const st = S();
for (const d of Object.keys(st.assignments)) {
if (d >= r.from && d <= r.to && !st.assignments[d].locked) delete st.assignments[d];
}
store.save();
res.json({ assignments: st.assignments });
});
app.put('/api/assignments/:date', (req, res) => {
const date = req.params.date;
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
const st = S();
const b = req.body || {};
const valid = (id) => id === null || id === undefined || st.persons.some((p) => p.id === id);
if (!valid(b.main) || !valid(b.co)) return bad(res, 'Person nicht gefunden.');
if (b.main && b.main === b.co) return bad(res, 'Haupt- und Nebenmoderator müssen verschieden sein.');
const current = st.assignments[date] || {};
st.assignments[date] = {
main: b.main !== undefined ? b.main : current.main ?? null,
co: b.co !== undefined ? b.co : current.co ?? null,
locked: b.locked !== undefined ? Boolean(b.locked) : Boolean(current.locked),
off: b.off !== undefined ? Boolean(b.off) : Boolean(current.off),
};
store.save();
res.json(st.assignments[date]);
});
app.post('/api/assignments/:date/reroll', (req, res) => {
const date = req.params.date;
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
const st = S();
const history = { ...st.assignments };
delete history[date];
const generated = generateSchedule({
dates: [date],
persons: personsForScheduling(),
absences: st.absences,
history,
blocked: blockedDays(),
historyWindowDays: st.settings.historyWindowDays,
});
// preview: nur Vorschlag liefern, Speichern übernimmt der Dialog
if (!req.body?.preview) {
st.assignments[date] = generated[date];
store.save();
}
res.json(generated[date]);
});
app.get('/api/stats', (req, res) => {
const r = parseRange(req.query);
if (r.error) return bad(res, r.error);
res.json(countAssignments(S().assignments, r.from, r.to));
});
// ---------- Tauschanfragen ----------
const requestHits = new Map(); // ip → Zeitpunkte der letzten Anfragen (Bremse: 10 pro Stunde)
app.post('/api/requests', (req, res) => {
const st = S();
const ip = clientIp(req);
const now = Date.now();
const hits = (requestHits.get(ip) || []).filter((t) => now - t < 3_600_000);
if (hits.length >= 10) return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später noch einmal.' });
if (st.requests.filter((r) => r.status === 'offen').length >= 200) return res.status(409).json({ error: 'Zu viele offene Anfragen. Bitte die Planung ansprechen.' });
const r = validateRequest(req.body, st.persons, st.assignments);
if (r.error) return bad(res, r.error);
const duplicate = st.requests.find((x) => x.status === 'offen' && x.personId === r.request.personId && x.date === r.request.date);
if (duplicate) return res.status(409).json({ error: 'Für diesen Tag liegt von dir schon eine offene Anfrage vor.' });
const request = { id: randomUUID(), ...r.request, status: 'offen', createdAt: new Date().toISOString(), resolvedAt: null };
st.requests.push(request);
hits.push(now);
requestHits.set(ip, hits);
store.save();
res.status(201).json({ id: request.id, status: request.status });
});
app.put('/api/requests/:id', (req, res) => {
const st = S();
const r = st.requests.find((x) => x.id === req.params.id);
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
const b = req.body || {};
if (b.status !== undefined) {
const status = String(b.status || '');
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
r.status = status;
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
}
// Bearbeiter dürfen den Wunsch nachtragen, z. B. aus dem Notizfeld
if (b.partnerId !== undefined || b.partnerDate !== undefined) {
const v = validateRequest({
personId: r.personId,
date: r.date,
partnerId: b.partnerId !== undefined ? b.partnerId : r.partnerId,
partnerDate: b.partnerDate !== undefined ? b.partnerDate : r.partnerDate,
note: r.note,
}, st.persons, st.assignments);
if (v.error) return bad(res, v.error);
r.partnerId = v.request.partnerId;
r.partnerDate = v.request.partnerDate;
r.role = v.request.role;
}
store.save();
res.json(r);
});
app.post('/api/requests/:id/execute', (req, res) => {
const st = S();
const r = st.requests.find((x) => x.id === req.params.id);
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
try {
const { changes, vacated } = applySwap(st.assignments, r, personsForScheduling());
let filled = null;
if (vacated) {
// Die frei gewordene Stelle gleich nachbesetzen – fair, ohne die anfragende Person
const existing = { [vacated.date]: { ...st.assignments[vacated.date], fill: true } };
const absences = [...st.absences, { personId: r.personId, from: vacated.date, to: vacated.date }];
const history = { ...st.assignments };
delete history[vacated.date];
const generated = generateSchedule({ dates: [vacated.date], persons: personsForScheduling(), absences, existing, history, blocked: blockedDays(), historyWindowDays: st.settings.historyWindowDays });
const picked = generated[vacated.date]?.[vacated.role] || null;
if (picked) {
st.assignments[vacated.date][vacated.role] = picked;
changes.push({ date: vacated.date, role: vacated.role, from: null, to: picked });
}
filled = { date: vacated.date, role: vacated.role, personId: picked };
}
r.status = 'erledigt';
r.resolvedAt = new Date().toISOString();
store.save();
res.json({ request: r, changes, filled, assignments: st.assignments });
} catch (err) {
bad(res, err.message);
}
});
app.delete('/api/requests/:id', (req, res) => {
const st = S();
const before = st.requests.length;
st.requests = st.requests.filter((x) => x.id !== req.params.id);
if (st.requests.length === before) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
store.save();
res.status(204).end();
});
// ---------- Fehlermeldungen ----------
const bugMeta = (b) => b;
app.post('/api/bugs', (req, res) => {
const st = S();
if (st.bugs.filter((b) => b.status === 'offen').length >= 500) return res.status(409).json({ error: 'Zu viele offene Meldungen. Bitte die Planung direkt ansprechen.' });
const b = req.body || {};
const text = clean(b.text, 2000);
if (!text) return bad(res, 'Bitte beschreibe kurz, was falsch läuft.');
let screenshot = null;
try { screenshot = parseScreenshot(b.screenshot); } catch (err) { return bad(res, err.message); }
const bug = {
id: randomUUID(),
createdAt: new Date().toISOString(),
text,
reporter: clean(b.reporter, 80),
page: clean(b.page, 120),
url: clean(b.url, 500),
viewport: clean(b.viewport, 40),
userAgent: clean(b.userAgent, 300),
version: appVersion,
status: 'offen',
resolvedAt: null,
hasScreenshot: Boolean(screenshot),
};
if (screenshot) {
store.saveBug(bug.id, screenshot);
// Ohne Content-Length (gestückelter Body) die echte Größe nachtragen
const hits = bugHits.get(clientIp(req)) || [];
const last = hits[hits.length - 1];
if (last && last.bytes === 0) last.bytes = screenshot.length;
}
st.bugs.push(bug);
store.save();
res.status(201).json({ id: bug.id });
});
const findBug = (req, res) => {
const bug = S().bugs.find((x) => x.id === req.params.id);
if (!bug) res.status(404).json({ error: 'Meldung nicht gefunden.' });
return bug;
};
app.get('/api/bugs/:id/prompt', (req, res) => {
const bug = findBug(req, res);
if (!bug) return;
res.type('text/plain; charset=utf-8').send(buildPrompt(S().settings.bugPromptTemplate, bug, { version: bug.version || appVersion }));
});
app.put('/api/bugs/:id', (req, res) => {
const bug = findBug(req, res);
if (!bug) return;
const status = String(req.body?.status || '');
if (!BUG_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
bug.status = status;
bug.resolvedAt = status === 'offen' ? null : new Date().toISOString();
store.save();
res.json(bugMeta(bug));
});
app.delete('/api/bugs/:id', (req, res) => {
const st = S();
const bug = findBug(req, res);
if (!bug) return;
st.bugs = st.bugs.filter((x) => x.id !== bug.id);
store.deleteBug(bug.id);
store.save();
res.status(204).end();
});
/** Screenshots nur für Bearbeiter – sie zeigen Namen und Planung. */
app.get('/bugs/:id.png', (req, res) => {
if (!req.isEditor) return res.status(403).type('text/plain').send('Anmeldung erforderlich.');
const id = String(req.params.id);
if (!/^[a-f0-9-]{36}$/.test(id)) return res.status(404).end();
const buf = store.readBug(id);
if (!buf) return res.status(404).end();
res.set('Cache-Control', 'private, max-age=3600');
res.type(imageMime(buf) || 'image/png');
res.send(buf);
});
// ---------- Export ----------
function eventsForRange(from, to) {
const st = S();
const byId = Object.fromEntries(st.persons.map((p) => [p.id, p]));
const blocked = blockedDays();
const organizer = st.settings.inviteAttendees && /@/.test(nc.user) ? { name: 'Morning Meeting (Rotatory)', email: nc.user } : null;
const events = [];
for (const [date, a] of Object.entries(st.assignments).sort()) {
if (date < from || date > to || a.off || blocked.has(date) || (!a.main && !a.co)) continue;
const main = byId[a.main];
const co = byId[a.co];
const attendees = organizer
? [main, co].filter((p) => p && p.email).map((p) => ({ name: personLabel(p), email: p.email }))
: [];
events.push({
date,
uid: eventUid(date, nc.baseUrl ? new URL(nc.baseUrl).hostname : 'rotatory.local'),
summary: `Morning Meeting: ${personLabel(main)} / ${personLabel(co)}`,
description: `Hauptmoderation: ${personLabel(main)}\nNebenmoderation: ${personLabel(co)}\n\nGeplant mit Rotatory.`,
time: st.settings.meetingTime,
durationMinutes: st.settings.durationMinutes,
attendees,
organizer,
sequence: (st.exported[date]?.sequence || 0),
});
}
return events;
}
app.get('/api/export.ics', (req, res) => {
const r = parseRange(req.query);
if (r.error) return bad(res, r.error);
const ics = buildCalendar(eventsForRange(r.from, r.to).map(buildEventLines), { name: S().settings.calendarDisplayName, method: 'PUBLISH' });
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Content-Disposition', `attachment; filename="morning-meeting-${r.from}-${r.to}.ics"`);
res.send(ics);
});
// ---------- Nextcloud ----------
function nextcloudClient() {
if (!nc.configured) throw new Error('Nextcloud ist nicht konfiguriert: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD setzen.');
return createNextcloudClient({ ...nc, displayName: S().settings.calendarDisplayName });
}
const ncError = (res, err) => res.status(502).json({ error: err.message });
app.post('/api/nextcloud/test', async (req, res) => {
try {
const r = await nextcloudClient().test();
res.json({ ...r, target: S().settings.targetCalendarHref, absenceSource: S().settings.absenceCalendarHref });
} catch (err) { ncError(res, err); }
});
app.get('/api/nextcloud/calendars', async (req, res) => {
try {
res.json({ calendars: await nextcloudClient().listCalendars(), defaultId: nc.calendar });
} catch (err) { ncError(res, err); }
});
app.post('/api/nextcloud/sync', async (req, res) => {
const r = parseRange(req.body);
if (r.error) return bad(res, r.error);
const removeForeign = Boolean(req.body.removeForeign);
const st = S();
if (req.body.targetCalendarHref !== undefined) {
const href = clean(req.body.targetCalendarHref, 500);
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
st.settings.targetCalendarHref = href;
st.settings.targetCalendarName = href ? clean(req.body.targetCalendarName, 120) : '';
}
try {
const client = nextcloudClient();
const { href, created } = await client.ensureCalendar(st.settings.targetCalendarHref || null);
// Zielwechsel: Termine im Zeitraum, die noch in einem anderen Kalender liegen, dort entfernen (sie werden gleich neu geschrieben)
const moved = [];
for (const [date, info] of Object.entries(st.exported)) {
if (date >= r.from && date <= r.to && info.calendarHref && info.calendarHref !== href) {
try { await client.deleteEvent(info.calendarHref, info.uid); moved.push(date); delete st.exported[date]; } catch { /* alter Kalender ggf. weg */ }
}
}
const events = eventsForRange(r.from, r.to);
const written = [];
const errors = [];
for (const ev of events) {
const sequence = (st.exported[ev.date]?.sequence || 0) + 1;
try {
await client.putEvent(href, ev.uid, buildCalendar([buildEventLines({ ...ev, sequence })]));
st.exported[ev.date] = { uid: ev.uid, sequence, at: new Date().toISOString(), calendarHref: href };
written.push(ev.date);
} catch (err) {
errors.push(`${ev.date}: ${err.message}`);
}
}
const removed = [];
const stillThere = new Set(events.map((e) => e.date));
for (const [date, info] of Object.entries(st.exported)) {
if (date < r.from || date > r.to || stillThere.has(date)) continue;
try {
await client.deleteEvent(href, info.uid);
delete st.exported[date];
removed.push(date);
} catch (err) {
errors.push(`${date}: ${err.message}`);
}
}
// Fremde Termine im Zeitraum (z. B. aus einer früheren Planung mit einem anderen Werkzeug)
let foreign = 0;
let foreignRemoved = 0;
let foreignRecurring = 0;
try {
const existing = await client.queryEvents(href, r.from, r.to);
for (const item of existing) {
const evs = parseEvents(item.ics);
if (evs.length === 0 || evs.some((e) => e.uid.startsWith('rotatory-'))) continue;
if (!evs.some((e) => e.to >= r.from && e.from <= r.to)) continue; // Server filtert grob, wir genau
if (evs.some((e) => e.rrule)) { foreignRecurring += 1; continue; } // ganze Serien nie löschen
foreign += 1;
if (removeForeign) {
try { await client.deleteEvent(href, item.href); foreignRemoved += 1; } catch (err) { errors.push(`Fremdtermin: ${err.message}`); }
}
}
} catch (err) {
errors.push(`Fremde Termine konnten nicht geprüft werden: ${err.message}`);
}
store.save();
res.json({ created, written: written.length, removed: removed.length, moved: moved.length, foreign, foreignRemoved, foreignRecurring, errors, calendarUrl: href });
} catch (err) {
store.save();
ncError(res, err);
}
});
/** Abwesenheiten aus einem Nextcloud-Kalender lesen ("Vorname Nachname (Urlaub)"). */
app.post('/api/nextcloud/absences/import', async (req, res) => {
const r = parseRange(req.body);
if (r.error) return bad(res, r.error);
const st = S();
const href = clean(req.body.calendarHref, 500) || st.settings.absenceCalendarHref;
if (!href) return bad(res, 'Kein Quellkalender gewählt.');
if (!isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
try {
const client = nextcloudClient();
const items = await client.queryEvents(href, r.from, r.to, { expand: true });
const found = [];
const unmatched = new Map();
let cancelled = 0;
for (const item of items) {
for (const ev of parseEvents(item.ics)) {
if (ev.status === 'CANCELLED') { cancelled += 1; continue; }
if (ev.to < r.from || ev.from > r.to) continue;
const { name, kind } = parseAbsenceSummary(ev.summary);
const person = matchPerson(name, st.persons);
if (!person) { unmatched.set(name, (unmatched.get(name) || 0) + 1); continue; }
found.push({ personId: person.id, from: ev.from, to: ev.to, type: kind });
}
}
const merged = mergeAbsences(found).map((a) => ({ id: randomUUID(), ...a, note: '', source: 'nextcloud' }));
// Früher importierte Abwesenheiten, die den Zeitraum berühren, ersetzen
const before = st.absences.length;
st.absences = st.absences.filter((a) => !(a.source === 'nextcloud' && a.from <= r.to && a.to >= r.from));
const replaced = before - st.absences.length;
st.absences.push(...merged);
if (req.body.calendarHref) { st.settings.absenceCalendarHref = href; st.settings.absenceCalendarName = clean(req.body.calendarName, 120); }
store.save();
res.json({ imported: merged.length, replaced, cancelled, events: items.length, unmatched: [...unmatched.entries()].map(([name, count]) => ({ name, count })), absences: st.absences });
} catch (err) { ncError(res, err); }
});
/** Kontakte (Nextcloud-Konten) zur Auswahl anbieten. */
app.get('/api/nextcloud/contacts', async (req, res) => {
try {
const client = nextcloudClient();
const books = await client.listAddressBooks();
if (books.length === 0) return res.json({ addressbooks: [], contacts: [] });
const wanted = clean(req.query.addressbook, 500);
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
const cards = await client.queryContacts(book.href);
const st = S();
const contacts = [];
for (const raw of cards) {
for (const c of parseVCards(raw)) {
if (!c.firstName && !c.lastName) continue;
const existing = st.persons.find((p) => (c.email && p.email === c.email) || (p.firstName.toLowerCase() === c.firstName.toLowerCase() && p.lastName.toLowerCase() === c.lastName.toLowerCase()));
contacts.push({ uid: c.uid, firstName: c.firstName, lastName: c.lastName, email: c.email, hasPhoto: Boolean(c.photo) || book.system, existingId: existing?.id || null });
}
}
contacts.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`, 'de'));
res.json({ addressbooks: books, addressbook: book.href, contacts });
} catch (err) { ncError(res, err); }
});
/** Ausgewählte Kontakte als Personen anlegen, Fotos mitnehmen. */
app.post('/api/nextcloud/contacts/import', async (req, res) => {
const uids = Array.isArray(req.body?.uids) ? req.body.uids.map((u) => clean(u, 200)).filter(Boolean) : [];
if (uids.length === 0) return bad(res, 'Keine Kontakte ausgewählt.');
try {
const client = nextcloudClient();
const books = await client.listAddressBooks();
const wanted = clean(req.body.addressbook, 500);
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
if (!book) return bad(res, 'Kein Adressbuch verfügbar.');
const cards = (await client.queryContacts(book.href)).flatMap(parseVCards);
const st = S();
const created = [];
const photos = [];
const skipped = [];
let placeholders = 0;
for (const uid of uids) {
const c = cards.find((x) => x.uid === uid);
if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; }
const r = readPerson({ firstName: c.firstName || c.lastName, lastName: c.lastName || c.firstName, email: c.email, note: '' });
if (r.error) { skipped.push(uid); continue; }
const person = { id: randomUUID(), ...r.person, photoVersion: 0, nextcloudUid: uid };
st.persons.push(person);
created.push(person.id);
let img = null;
if (book.system) img = await client.fetchAvatar(uid).catch(() => null);
if (!img && c.photo?.kind === 'inline') { const buffer = Buffer.from(c.photo.base64, 'base64'); if (imageMime(buffer)) img = { buffer }; }
if (!img && c.photo?.kind === 'url') img = await client.fetchBinary(c.photo.url).catch(() => null);
// Generierte Initialen-Avatare zählen nicht als Foto, auch wenn Nextcloud sie als „eigen" meldet
if (img && imageMime(img.buffer) && !looksLikePlaceholder(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); }
else if (img) placeholders += 1;
}
store.save();
res.json({ created: created.length, withPhoto: photos.length, placeholders, skipped: skipped.length, persons: st.persons.map(publicPerson) });
} catch (err) { ncError(res, err); }
});
app.use('/api', (req, res) => res.status(404).json({ error: 'Unbekannter Endpunkt.' }));
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.status || err.statusCode || 500;
res.status(status).json({ error: status === 500 ? 'Interner Fehler.' : err.message });
if (status === 500) console.error(err);
});
export function start(port = Number(process.env.PORT) || 3000) {
return app.listen(port, () => {
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
});
}
// Nur beim direkten Start lauschen; Tests importieren die App und starten sie auf einem freien Port
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) start();