Betrachter klicken ihren Tag an, wählen ihren Namen, optional Wunschperson und Wunschtag, und schreiben eine Notiz; keine Anmeldung nötig, zehn Anfragen je Stunde und Adresse. Offene Anfragen markieren den Tag im Kalender. Bearbeiter sehen sie unter „Anfragen" mit Zähler und führen den Tausch aus (über Kreuz oder Übernahme), öffnen den Tag oder lehnen ab. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
757 lines
33 KiB
JavaScript
757 lines
33 KiB
JavaScript
import express from 'express';
|
||
import path from 'node:path';
|
||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||
import { fileURLToPath } 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 { 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).');
|
||
}
|
||
|
||
const app = express();
|
||
app.disable('x-powered-by');
|
||
app.set('etag', false);
|
||
|
||
// 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.');
|
||
});
|
||
}
|
||
|
||
app.use(express.json({ limit: '1mb' }));
|
||
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 clientIp = (req) => (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?';
|
||
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();
|
||
});
|
||
const needsEditor = (req) => {
|
||
if (!req.path.startsWith('/api/')) return false;
|
||
if (req.path.startsWith('/api/auth')) return false;
|
||
if (req.method === 'POST' && req.path === '/api/requests') return false; // Tauschanfragen dürfen alle stellen
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') return true;
|
||
return req.path.startsWith('/api/nextcloud/') || req.path.startsWith('/api/persons/placeholder');
|
||
};
|
||
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: {},
|
||
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))],
|
||
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;
|
||
}
|
||
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 status = String(req.body?.status || '');
|
||
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
||
r.status = status;
|
||
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
||
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 } = applySwap(st.assignments, r);
|
||
r.status = 'erledigt';
|
||
r.resolvedAt = new Date().toISOString();
|
||
store.save();
|
||
res.json({ request: r, changes, 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();
|
||
});
|
||
|
||
// ---------- 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);
|
||
});
|
||
|
||
const port = Number(process.env.PORT) || 3000;
|
||
app.listen(port, () => {
|
||
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
|
||
});
|