Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben

Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import fs from 'node:fs';
/** Lädt eine .env-Datei in process.env, ohne bereits gesetzte Variablen zu überschreiben. */
export function loadEnvFile(file) {
if (!fs.existsSync(file)) return false;
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!m) continue;
let value = m[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
else if (value.startsWith('#')) value = ''; // "KEY= # optional" – Kommentar ist kein Wert
else value = value.replace(/\s+#.*$/, '').trim();
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
}
return true;
}
+66
View File
@@ -0,0 +1,66 @@
import { addDays } from './scheduler.js';
const pad = (n) => String(n).padStart(2, '0');
const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
/** Ostersonntag nach der Gauß'schen Osterformel (Meeus/Jones/Butcher). */
export function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return iso(year, month, day);
}
export const BUNDESLAENDER = {
'': 'Nur bundesweite Feiertage',
BW: 'Baden-Württemberg', BY: 'Bayern', BE: 'Berlin', BB: 'Brandenburg', HB: 'Bremen',
HH: 'Hamburg', HE: 'Hessen', MV: 'Mecklenburg-Vorpommern', NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen', RP: 'Rheinland-Pfalz', SL: 'Saarland', SN: 'Sachsen',
ST: 'Sachsen-Anhalt', SH: 'Schleswig-Holstein', TH: 'Thüringen',
};
function bussUndBettag(year) {
// Mittwoch vor dem 23. November
let d = iso(year, 11, 22);
while (new Date(d + 'T00:00:00Z').getUTCDay() !== 3) d = addDays(d, -1);
return d;
}
/** Gesetzliche Feiertage eines Jahres, bundesweit plus die des gewählten Bundeslands. */
export function germanHolidays(year, state = '') {
const easter = easterSunday(year);
const all = [
{ date: iso(year, 1, 1), name: 'Neujahr' },
{ date: iso(year, 1, 6), name: 'Heilige Drei Könige', states: ['BW', 'BY', 'ST'] },
{ date: iso(year, 3, 8), name: 'Internationaler Frauentag', states: ['BE', 'MV'] },
{ date: addDays(easter, -2), name: 'Karfreitag' },
{ date: addDays(easter, 1), name: 'Ostermontag' },
{ date: iso(year, 5, 1), name: 'Tag der Arbeit' },
{ date: addDays(easter, 39), name: 'Christi Himmelfahrt' },
{ date: addDays(easter, 50), name: 'Pfingstmontag' },
{ date: addDays(easter, 60), name: 'Fronleichnam', states: ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'] },
{ date: iso(year, 8, 15), name: 'Mariä Himmelfahrt', states: ['SL', 'BY'] },
{ date: iso(year, 9, 20), name: 'Weltkindertag', states: ['TH'] },
{ date: iso(year, 10, 3), name: 'Tag der Deutschen Einheit' },
{ date: iso(year, 10, 31), name: 'Reformationstag', states: ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'] },
{ date: iso(year, 11, 1), name: 'Allerheiligen', states: ['BW', 'BY', 'NW', 'RP', 'SL'] },
{ date: bussUndBettag(year), name: 'Buß- und Bettag', states: ['SN'] },
{ date: iso(year, 12, 25), name: '1. Weihnachtstag' },
{ date: iso(year, 12, 26), name: '2. Weihnachtstag' },
];
return all
.filter((h) => !h.states || h.states.includes(state))
.map(({ date, name }) => ({ date, name, auto: true }))
.sort((a, b) => a.date.localeCompare(b.date));
}
+260
View File
@@ -0,0 +1,260 @@
// Lesen von iCalendar (VEVENT) und vCard – nur was Rotatory braucht, ohne Abhängigkeiten.
const berlinDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' });
/** Gefaltete Zeilen (CRLF + Leerzeichen/Tab) wieder zusammenfügen. */
export function unfold(text) {
return String(text).replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter((l) => l.length > 0);
}
/** Eine Content-Line in Name, Parameter und Wert zerlegen. */
export function parseLine(line) {
let i = 0;
let inQuotes = false;
while (i < line.length && (inQuotes || line[i] !== ':')) {
if (line[i] === '"') inQuotes = !inQuotes;
i += 1;
}
const head = line.slice(0, i);
const value = line.slice(i + 1);
const [rawName, ...rawParams] = head.split(';');
const params = {};
for (const p of rawParams) {
const eq = p.indexOf('=');
if (eq < 0) { params[p.toUpperCase()] = ''; continue; }
params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, '');
}
return { name: rawName.toUpperCase(), params, value };
}
export function unescapeText(s) {
// Ein Durchgang, damit '\\\\n' (maskierter Backslash + n) nicht zum Zeilenumbruch wird
return String(s ?? '').replace(/\\([\\;,nN])/g, (m, c) => (c === 'n' || c === 'N' ? '\n' : c));
}
/** Datum/Zeit-Wert → { date: 'YYYY-MM-DD' (lokal Europe/Berlin), allDay, instant: Date|null }. */
export function toLocalDate(value, params = {}) {
const v = String(value).trim();
if (params.VALUE === 'DATE' || /^\d{8}$/.test(v)) {
return { date: `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`, allDay: true, instant: null };
}
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/);
if (!m) return null;
const [, y, mo, d, h, mi, s = '00', z] = m;
if (z) {
const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
return { date: berlinDate.format(instant), allDay: false, instant };
}
// Wandzeit (TZID oder floating): Datum wie angegeben verwenden
return { date: `${y}-${mo}-${d}`, allDay: false, instant: new Date(+y, +mo - 1, +d, +h, +mi, +s) };
}
function addDays(iso, n) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function parseDuration(s) {
const m = String(s).match(/^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
if (!m) return null;
const sign = m[1] ? -1 : 1;
const days = (+m[2] || 0) * 7 + (+m[3] || 0);
const seconds = (+m[4] || 0) * 3600 + (+m[5] || 0) * 60 + (+m[6] || 0);
return { days: sign * days, seconds: sign * seconds };
}
/**
* Alle VEVENTs eines iCalendar-Textes als { uid, summary, from, to, allDay, rrule, recurrenceId, status, description }.
* from/to sind lokale Kalendertage (inklusiv), Zeitzone Europe/Berlin.
*/
export function parseEvents(text) {
const events = [];
let cur = null;
let depth = [];
for (const line of unfold(text)) {
const { name, params, value } = parseLine(line);
if (name === 'BEGIN') { depth.push(value.toUpperCase()); if (value.toUpperCase() === 'VEVENT') cur = { props: [] }; continue; }
if (name === 'END') {
const closed = depth.pop();
if (closed === 'VEVENT' && cur) { const ev = finishEvent(cur.props); if (ev) events.push(ev); cur = null; }
continue;
}
if (cur && depth[depth.length - 1] === 'VEVENT') cur.props.push({ name, params, value });
}
return events;
}
function finishEvent(props) {
const get = (n) => props.find((p) => p.name === n);
const start = get('DTSTART');
if (!start) return null;
const s = toLocalDate(start.value, start.params);
if (!s) return null;
let to = s.date;
const end = get('DTEND');
const dur = get('DURATION');
if (end) {
const e = toLocalDate(end.value, end.params);
if (e) {
if (e.allDay) to = addDays(e.date, -1);
else if (e.instant) to = s.allDay ? e.date : berlinOrWall(new Date(e.instant.getTime() - 1000), end.params, end.value);
else to = e.date;
}
} else if (dur) {
const d = parseDuration(dur.value);
if (d) {
if (s.allDay) to = addDays(s.date, Math.max(0, d.days - 1));
else {
const endInstant = new Date(s.instant.getTime() + (d.days * 86400 + d.seconds) * 1000 - 1000);
to = berlinOrWall(endInstant, start.params, start.value);
}
}
}
if (to < s.date) to = s.date;
return {
uid: get('UID')?.value || '',
summary: unescapeText(get('SUMMARY')?.value || ''),
description: unescapeText(get('DESCRIPTION')?.value || ''),
from: s.date,
to,
allDay: s.allDay,
rrule: get('RRULE')?.value || null,
recurrenceId: get('RECURRENCE-ID')?.value || null,
status: (get('STATUS')?.value || '').toUpperCase(),
};
}
function berlinOrWall(instant, params, originalValue) {
if (/Z$/.test(originalValue)) return berlinDate.format(instant);
const pad = (n) => String(n).padStart(2, '0');
return `${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}`;
}
/** vCards (3.0/4.0) → [{ uid, fn, firstName, lastName, email, cloudId, photo }] */
export function parseVCards(text) {
const cards = [];
let cur = null;
for (const line of unfold(text)) {
const { name, params, value } = parseLine(line);
if (name === 'BEGIN' && value.toUpperCase() === 'VCARD') { cur = []; continue; }
if (name === 'END' && value.toUpperCase() === 'VCARD') { if (cur) cards.push(finishCard(cur)); cur = null; continue; }
if (cur) cur.push({ name, params, value });
}
return cards;
}
function splitEscaped(value, sep = ';') {
const out = [];
let buf = '';
for (let i = 0; i < value.length; i += 1) {
if (value[i] === '\\' && i + 1 < value.length) { buf += value[i] + value[i + 1]; i += 1; continue; }
if (value[i] === sep) { out.push(buf); buf = ''; continue; }
buf += value[i];
}
out.push(buf);
return out.map(unescapeText);
}
function finishCard(props) {
const get = (n) => props.find((p) => p.name === n);
const fn = unescapeText(get('FN')?.value || '').trim();
let firstName = '';
let lastName = '';
const n = get('N');
if (n) {
const parts = splitEscaped(n.value);
lastName = (parts[0] || '').trim();
firstName = [parts[1], parts[2]].filter(Boolean).join(' ').trim();
}
if (!firstName && !lastName && fn) {
const idx = fn.lastIndexOf(' ');
if (idx > 0) { firstName = fn.slice(0, idx).trim(); lastName = fn.slice(idx + 1).trim(); } else lastName = fn;
} else if (!firstName && fn && fn !== lastName) {
firstName = fn.replace(lastName, '').trim();
}
const emails = props.filter((p) => p.name === 'EMAIL').map((p) => unescapeText(p.value).trim().toLowerCase()).filter(Boolean);
const photoProp = get('PHOTO');
let photo = null;
if (photoProp) {
const v = photoProp.value.trim();
const enc = (photoProp.params.ENCODING || '').toUpperCase();
const dataUrl = v.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/is);
if (dataUrl) photo = { kind: 'inline', mime: dataUrl[1] || 'image/jpeg', base64: dataUrl[2].replace(/\s+/g, '') };
else if (enc === 'B' || enc === 'BASE64') photo = { kind: 'inline', mime: `image/${(photoProp.params.TYPE || 'jpeg').toLowerCase().replace(/^image\//, '')}`, base64: v.replace(/\s+/g, '') };
else if (/^https?:\/\//i.test(v)) photo = { kind: 'url', url: v };
}
return {
uid: unescapeText(get('UID')?.value || '').trim(),
fn,
firstName,
lastName,
email: emails[0] || '',
cloudId: unescapeText(get('CLOUD')?.value || get('X-CLOUD')?.value || '').trim(),
photo,
};
}
/**
* Titel eines Abwesenheitstermins deuten: "Vorname Nachname (Urlaub)" → { name, kind }.
* kind: urlaub | krankheit | frei | sonstiges
*/
export function parseAbsenceSummary(summary) {
const s = String(summary || '').trim();
const m = s.match(/^(.*?)\s*[\(\[]([^\)\]]*)[\)\]]\s*$/);
const name = (m ? m[1] : s).trim();
const raw = (m ? m[2] : '').trim();
const k = raw.toLowerCase();
let kind = 'sonstiges';
if (/urlaub|vacation|holiday|ferien/.test(k)) kind = 'urlaub';
else if (/krank|sick|kur|reha/.test(k)) kind = 'krankheit';
else if (/frei|off|gleit|elternzeit|home/.test(k)) kind = 'frei';
return { name, kind, raw };
}
export function normalizeName(s) {
return String(s || '').normalize('NFC').toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ').trim();
}
/** Ordnet einen Namen einer Person zu (Vor+Nach, Nach+Vor, eindeutiger Nachname/Vorname). */
export function matchPerson(name, persons) {
const key = normalizeName(name);
if (!key) return null;
for (const p of persons) {
const f = normalizeName(p.firstName);
const l = normalizeName(p.lastName);
if (key === `${f} ${l}`.trim() || key === `${l} ${f}`.trim()) return p;
}
const tokens = key.split(' ');
if (tokens.length === 1) {
const byLast = persons.filter((p) => normalizeName(p.lastName) === key);
if (byLast.length === 1) return byLast[0];
const byFirst = persons.filter((p) => normalizeName(p.firstName) === key);
if (byFirst.length === 1) return byFirst[0];
}
return null;
}
/** Zusammenhängende oder überlappende Zeiträume je Person und Art verschmelzen. */
export function mergeAbsences(items) {
const groups = new Map();
for (const a of items) {
const k = `${a.personId}|${a.type}`;
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(a);
}
const out = [];
for (const list of groups.values()) {
list.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
let cur = null;
for (const a of list) {
if (cur && a.from <= addDays(cur.to, 1)) { if (a.to > cur.to) cur.to = a.to; continue; }
cur = { ...a };
out.push(cur);
}
}
return out.sort((a, b) => a.from.localeCompare(b.from));
}
+126
View File
@@ -0,0 +1,126 @@
// iCalendar-Erzeugung (RFC 5545) ohne Abhängigkeiten.
const CRLF = '\r\n';
export function escapeText(s) {
return String(s ?? '')
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
/** Zeilen länger als 75 Oktette werden mit CRLF + Leerzeichen gefaltet. */
export function foldLine(line) {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const parts = [];
let start = 0;
let limit = 75;
while (start < bytes.length) {
let end = Math.min(start + limit, bytes.length);
// Nicht mitten in einer UTF-8-Sequenz trennen
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1;
parts.push((parts.length ? ' ' : '') + bytes.subarray(start, end).toString('utf8'));
start = end;
limit = 74;
}
return parts.join(CRLF);
}
const VTIMEZONE_BERLIN = [
'BEGIN:VTIMEZONE',
'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT',
'TZOFFSETFROM:+0100',
'TZOFFSETTO:+0200',
'TZNAME:CEST',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
'END:DAYLIGHT',
'BEGIN:STANDARD',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0100',
'TZNAME:CET',
'DTSTART:19701025T030000',
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
'END:STANDARD',
'END:VTIMEZONE',
];
function stamp(d = new Date()) {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function addMinutes(hhmm, minutes) {
const [h, m] = hhmm.split(':').map(Number);
const total = h * 60 + m + minutes;
const day = Math.floor(total / (24 * 60));
const rest = total % (24 * 60);
return { day, time: `${String(Math.floor(rest / 60)).padStart(2, '0')}${String(rest % 60).padStart(2, '0')}00` };
}
/**
* Ein VEVENT als Array von (ungefalteten) Zeilen.
* time = 'HH:MM' oder leer für ganztägig.
*/
export function buildEventLines({ uid, date, summary, description = '', time = '', durationMinutes = 15, attendees = [], organizer = null, sequence = 0, now = new Date() }) {
const compact = date.replace(/-/g, '');
const lines = ['BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${stamp(now)}`, `SEQUENCE:${sequence}`];
if (time) {
const start = addMinutes(time, 0).time;
const end = addMinutes(time, durationMinutes);
const endDate = end.day ? addDaysCompact(date, end.day) : compact;
lines.push(`DTSTART;TZID=Europe/Berlin:${compact}T${start}`);
lines.push(`DTEND;TZID=Europe/Berlin:${endDate}T${end.time}`);
} else {
lines.push(`DTSTART;VALUE=DATE:${compact}`);
lines.push(`DTEND;VALUE=DATE:${addDaysCompact(date, 1)}`);
}
lines.push(`SUMMARY:${escapeText(summary)}`);
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
// Teilnehmer nur mit ORGANIZER – ohne ihn verschickt Nextcloud keine Einladungen und iCal-Clients meckern
if (attendees.length > 0 && organizer?.email) {
lines.push(`ORGANIZER;CN=${escapeParam(organizer.name || organizer.email)}:mailto:${organizer.email}`);
for (const a of attendees) {
lines.push(`ATTENDEE;CN=${escapeParam(a.name)};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:${a.email}`);
}
}
lines.push('TRANSP:OPAQUE', 'END:VEVENT');
return lines;
}
function escapeParam(s) {
return '"' + String(s ?? '').replace(/["\r\n]/g, '') + '"';
}
function addDaysCompact(iso, n) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* Vollständiger Kalender aus einer Liste von Event-Zeilenarrays.
* method nur für Dateien setzen (z. B. PUBLISH) – CalDAV-Objekte dürfen laut RFC 4791 keine METHOD tragen,
* Nextcloud lehnt einen PUT damit ab.
*/
export function buildCalendar(eventLineArrays, { name = 'Morning Meeting', method = '' } = {}) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Rotatory//Morning Meeting//DE',
'CALSCALE:GREGORIAN',
...(method ? [`METHOD:${method}`] : []),
`X-WR-CALNAME:${escapeText(name)}`,
'X-WR-TIMEZONE:Europe/Berlin',
...VTIMEZONE_BERLIN,
...eventLineArrays.flat(),
'END:VCALENDAR',
];
return lines.map(foldLine).join(CRLF) + CRLF;
}
export function eventUid(date, host = 'rotatory.local') {
return `rotatory-${date}@${host}`;
}
+612
View File
@@ -0,0 +1,612 @@
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 { 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')));
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();
res.json({
persons: st.persons.map(publicPerson),
absences: st.absences,
holidays: st.holidays,
assignments: st.assignments,
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();
});
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));
});
// ---------- 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 = [];
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);
if (img && imageMime(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); }
}
store.save();
res.json({ created: created.length, withPhoto: photos.length, 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'}`);
});
+227
View File
@@ -0,0 +1,227 @@
// CalDAV/CardDAV-Client für Nextcloud ohne Abhängigkeiten: Discovery, Kalender, Termine, Adressbuch.
export function nextcloudConfigFromEnv(env = process.env) {
const baseUrl = (env.NEXTCLOUD_URL || '').trim().replace(/\/+$/, '');
const user = (env.NEXTCLOUD_USER || '').trim();
const password = (env.NEXTCLOUD_APP_PASSWORD || '').trim();
const calendar = (env.NEXTCLOUD_CALENDAR || 'morning-meeting').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return { baseUrl, user, password, calendar, configured: Boolean(baseUrl && user && password) };
}
// ---------- XML-Hilfen (namespace-präfix-unabhängig) ----------
export function decodeXml(s) {
return String(s ?? '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (m, d) => String.fromCodePoint(Number(d)))
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
}
export function encodeXml(s) {
return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
}
// Lokaler Name mit beliebigem Präfix; das Ende ist erreicht, wenn kein Namenszeichen folgt ("write" ≠ "write-properties").
const tag = (local) => `(?:[A-Za-z0-9_-]+:)?${local}(?![A-Za-z0-9_-])`;
export function xmlResponses(xml) {
return [...String(xml).matchAll(new RegExp(`<${tag('response')}[^>]*>([\\s\\S]*?)</${tag('response')}\\s*>`, 'g'))].map((m) => m[1]);
}
export function xmlProp(block, local) {
const m = String(block ?? '').match(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`));
return m ? m[1] : null;
}
export function xmlHasEmpty(block, local) {
return new RegExp(`<${tag(local)}[^>]*/>`).test(String(block ?? ''));
}
export function xmlAll(block, local) {
return [...String(block ?? '').matchAll(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`, 'g'))].map((m) => m[1]);
}
const PROPFIND_BODY = (props) => `<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:prop>${props}</d:prop></d:propfind>`;
export function createNextcloudClient({ baseUrl, user, password, calendar = 'morning-meeting', displayName = 'Morning Meeting', fetchImpl = fetch }) {
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
const abs = (href) => new URL(href, baseUrl + '/').toString();
let discovered = null;
async function request(method, url, { headers = {}, body } = {}) {
try {
return await fetchImpl(url, { method, headers: { Authorization: auth, 'User-Agent': 'Rotatory/1.0', ...headers }, body, redirect: 'manual' });
} catch (err) {
throw new Error(`Nextcloud nicht erreichbar: ${err.cause?.message || err.message}`);
}
}
async function dav(method, url, body, depth = '0') {
const res = await request(method, url, { headers: { Depth: depth, 'Content-Type': 'application/xml; charset=utf-8' }, body });
if (res.status === 401) throw new Error('Nextcloud lehnt die Zugangsdaten ab (401). Benutzername oder App-Passwort prüfen.');
return res;
}
async function propfind(url, props, depth = '0') {
const res = await dav('PROPFIND', url, PROPFIND_BODY(props), depth);
return { status: res.status, xml: res.status === 207 ? await res.text() : '' };
}
const client = {
/** Principal, Kalender- und Adressbuch-Home des angemeldeten Kontos ermitteln. */
async discover() {
if (discovered) return discovered;
const root = await propfind(abs('remote.php/dav/'), '<d:current-user-principal/>');
if (root.status !== 207) throw new Error(`Nextcloud antwortet mit HTTP ${root.status} auf ${abs('remote.php/dav/')}`);
const principalHref = decodeXml(xmlProp(xmlProp(root.xml, 'current-user-principal'), 'href'));
if (!principalHref) throw new Error('Nextcloud nennt keinen Principal – ist das eine Nextcloud-URL?');
const p = await propfind(abs(principalHref), '<c:calendar-home-set/><card:addressbook-home-set/><d:displayname/>');
if (p.status !== 207) throw new Error(`Principal nicht lesbar: HTTP ${p.status}`);
const calendarHome = decodeXml(xmlProp(xmlProp(p.xml, 'calendar-home-set'), 'href'));
const addressbookHome = decodeXml(xmlProp(xmlProp(p.xml, 'addressbook-home-set'), 'href'));
if (!calendarHome) throw new Error('Kein Kalender-Home gefunden – ist die Kalender-App aktiviert?');
discovered = {
principalHref,
userId: decodeURIComponent(principalHref.replace(/\/+$/, '').split('/').pop()),
displayName: decodeXml(xmlProp(p.xml, 'displayname') || ''),
calendarHome: abs(calendarHome),
addressbookHome: addressbookHome ? abs(addressbookHome) : null,
};
return discovered;
},
async test() {
const d = await client.discover();
const calendars = await client.listCalendars();
return { ok: true, userId: d.userId, displayName: d.displayName, calendarHome: d.calendarHome, calendars };
},
/** Alle Ereigniskalender (eigene und geteilte) mit Schreibrecht-Information. */
async listCalendars() {
const d = await client.discover();
const r = await propfind(d.calendarHome, '<d:displayname/><d:resourcetype/><d:owner/><d:current-user-privilege-set/><c:supported-calendar-component-set/><oc:owner-displayname/>', '1');
if (r.status !== 207) throw new Error(`Kalenderliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const href = decodeXml(xmlProp(block, 'href') || '');
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'calendar')) continue;
const comps = xmlProp(block, 'supported-calendar-component-set') || '';
const supportsEvents = !comps || /name="VEVENT"/.test(comps);
if (!supportsEvents) continue;
const priv = xmlProp(block, 'current-user-privilege-set') || '';
const writable = xmlHasEmpty(priv, 'write') || xmlHasEmpty(priv, 'write-content') || xmlHasEmpty(priv, 'all');
const ownerHref = decodeXml(xmlProp(xmlProp(block, 'owner'), 'href') || '');
const ownerId = ownerHref ? decodeURIComponent(ownerHref.replace(/\/+$/, '').split('/').pop()) : '';
out.push({
href: abs(href),
id: href.replace(/\/+$/, '').split('/').pop(),
name: decodeXml(xmlProp(block, 'displayname') || ''),
writable,
shared: ownerId !== d.userId,
ownerName: decodeXml(xmlProp(block, 'owner-displayname') || '') || ownerId,
});
}
return out.sort((a, b) => a.name.localeCompare(b.name, 'de'));
},
/** Zielkalender bereitstellen: vorhandener (href) oder eigener Kalender, der bei Bedarf angelegt wird. */
async ensureCalendar(targetHref) {
const d = await client.discover();
if (targetHref) {
const r = await propfind(targetHref, '<d:resourcetype/><d:current-user-privilege-set/>');
if (r.status !== 207) throw new Error(`Zielkalender nicht erreichbar (HTTP ${r.status}). In den Nextcloud-Einstellungen neu wählen.`);
return { href: targetHref, created: false };
}
const href = `${d.calendarHome}${encodeURIComponent(calendar)}/`;
const r = await propfind(href, '<d:resourcetype/>');
if (r.status === 207) return { href, created: false };
if (r.status !== 404) throw new Error(`Kalender-Abfrage fehlgeschlagen: HTTP ${r.status}`);
const body = '<?xml version="1.0" encoding="utf-8"?>'
+ '<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop>'
+ `<d:displayname>${encodeXml(displayName)}</d:displayname>`
+ '<a:calendar-color>#2f6fed</a:calendar-color>'
+ '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
+ '</d:prop></d:set></c:mkcalendar>';
const mk = await dav('MKCALENDAR', href, body);
if (mk.status < 200 || mk.status >= 300) throw new Error(`Kalender konnte nicht angelegt werden: HTTP ${mk.status}`);
return { href, created: true };
},
async putEvent(calendarHref, uid, ics) {
const res = await request('PUT', `${calendarHref}${encodeURIComponent(uid)}.ics`, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, body: ics });
if (res.status < 200 || res.status >= 300) {
const text = await res.text().catch(() => '');
throw new Error(`Termin ${uid} nicht gespeichert: HTTP ${res.status} ${text.replace(/<[^>]+>/g, ' ').trim().slice(0, 160)}`);
}
},
async deleteEvent(calendarHref, uidOrHref) {
const url = /^https?:\/\//.test(uidOrHref) ? uidOrHref : `${calendarHref}${encodeURIComponent(uidOrHref)}.ics`;
const res = await request('DELETE', url);
if (res.status === 404) return false;
if (res.status < 200 || res.status >= 300) throw new Error(`Termin nicht gelöscht: HTTP ${res.status}`);
return true;
},
/** Termine eines Kalenders im Zeitraum (ISO-Tage, inklusiv). expand: Wiederholungen serverseitig auflösen. */
async queryEvents(calendarHref, from, to, { expand = false } = {}) {
const start = `${from.replace(/-/g, '')}T000000Z`;
const endDate = new Date(to + 'T00:00:00Z');
endDate.setUTCDate(endDate.getUTCDate() + 1);
// Der Server filtert grob (UTC-Mitternacht); Aufrufer prüfen die Überlappung mit den lokalen Tagen selbst.
const end = `${endDate.toISOString().slice(0, 10).replace(/-/g, '')}T000000Z`;
const data = expand ? `<c:calendar-data><c:expand start="${start}" end="${end}"/></c:calendar-data>` : '<c:calendar-data/>';
const body = `<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/>${data}</d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${start}" end="${end}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
const res = await dav('REPORT', calendarHref, body, '1');
if (res.status !== 207) throw new Error(`Terminabfrage fehlgeschlagen: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => ({
href: abs(decodeXml(xmlProp(block, 'href') || '')),
etag: decodeXml(xmlProp(block, 'getetag') || ''),
ics: decodeXml(xmlProp(block, 'calendar-data') || ''),
})).filter((e) => e.ics);
},
async listAddressBooks() {
const d = await client.discover();
if (!d.addressbookHome) return [];
const r = await propfind(d.addressbookHome, '<d:displayname/><d:resourcetype/>', '1');
if (r.status !== 207) throw new Error(`Adressbuchliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'addressbook')) continue;
const href = decodeXml(xmlProp(block, 'href') || '');
const id = href.replace(/\/+$/, '').split('/').pop();
out.push({ href: abs(href), id, name: decodeXml(xmlProp(block, 'displayname') || id), system: id === 'z-server-generated--system' });
}
return out.sort((a, b) => Number(b.system) - Number(a.system) || a.name.localeCompare(b.name, 'de'));
},
/** Alle vCards eines Adressbuchs als Text. */
async queryContacts(addressbookHref) {
const body = '<?xml version="1.0" encoding="utf-8"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>';
const res = await dav('REPORT', addressbookHref, body, '1');
if (res.status !== 207) throw new Error(`Adressbuch nicht lesbar: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => decodeXml(xmlProp(block, 'address-data') || '')).filter(Boolean);
},
/** Profilbild eines Nextcloud-Kontos; null, wenn keines gesetzt ist (Buchstaben-Avatar zählt nicht). */
async fetchAvatar(userId, size = 256) {
const res = await request('GET', abs(`index.php/avatar/${encodeURIComponent(userId)}/${size}`));
if (res.status !== 200) return null;
const custom = res.headers.get('x-nc-iscustomavatar');
if (custom !== null && custom !== '1') return null;
const type = res.headers.get('content-type') || '';
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type.split(';')[0] };
},
/** Bild von einer URL – nur innerhalb der eigenen Nextcloud, damit das App-Passwort nie an fremde Hosts geht. */
async fetchBinary(url) {
let target;
try { target = new URL(url); } catch { return null; }
if (target.origin !== new URL(baseUrl).origin) return null;
const res = await request('GET', url);
if (res.status !== 200) return null;
const type = (res.headers.get('content-type') || '').split(';')[0];
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type };
},
};
return client;
}
+151
View File
@@ -0,0 +1,151 @@
// Reine Planungslogik ohne Abhängigkeiten. Datumsangaben sind immer ISO-Strings (YYYY-MM-DD).
export function addDays(iso, n) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
export function isWeekend(iso) {
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
return day === 0 || day === 6;
}
/** ISO-Wochentag 1 (Mo) … 7 (So). */
export function isoWeekday(iso) {
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
return day === 0 ? 7 : day;
}
export function isValidDate(iso) {
return typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso) && !Number.isNaN(Date.parse(iso + 'T00:00:00Z'))
&& new Date(iso + 'T00:00:00Z').toISOString().slice(0, 10) === iso;
}
/** Alle Werktage (Mo–Fr) im Bereich, ohne die blockierten Tage (Feiertage, betriebsfrei). */
export function workingDays(from, to, blocked = new Set()) {
const out = [];
for (let d = from; d <= to; d = addDays(d, 1)) {
if (!isWeekend(d) && !blocked.has(d)) out.push(d);
}
return out;
}
export function isAbsent(personId, date, absences) {
return absences.some((a) => a.personId === personId && a.from <= date && a.to >= date);
}
/** Verfügbar = aktiv, nicht abwesend und kein fester freier Wochentag (Teilzeit). */
export function isAvailable(person, date, absences) {
if (person.active === false) return false;
if (Array.isArray(person.freeDays) && person.freeDays.includes(isoWeekday(date))) return false;
return !isAbsent(person.id, date, absences);
}
/**
* Erzeugt Zuteilungen für die angegebenen Tage.
*
* - persons: [{ id, canMain, canCo, active, freeDays }]
* - absences: [{ personId, from, to }]
* - existing: { date: { main, co, locked, off, keep, fill } } – locked/off/keep bleiben unverändert,
* fill: vorhandene Slots behalten, nur leere auslosen
* - blocked: Set blockierter Tage (Feiertage), damit „nicht zwei Werktage in Folge" darüber hinweg gilt
* - history: { date: { main, co, off } } – frühere Zuteilungen für die Fairness-Zählung
* - rng: Zufallsfunktion [0,1), austauschbar für Tests
*
* Fairness: Wer im Betrachtungsfenster am seltensten dran war, wird bevorzugt; unter Gleichen
* entscheidet die Rollenverteilung, danach der Zufall. Niemand moderiert an zwei Werktagen
* hintereinander, wenn es sich vermeiden lässt, und dasselbe Paar wiederholt sich ungern.
*/
export function generateSchedule({ dates, persons, absences = [], existing = {}, history = {}, blocked = new Set(), rng = Math.random, historyWindowDays = 60 }) {
const result = {};
const counts = new Map();
const stat = (id) => {
if (!counts.has(id)) counts.set(id, { total: 0, main: 0, co: 0, lastPartner: null });
return counts.get(id);
};
const bump = (id, role, partner) => {
const s = stat(id);
s.total += 1;
s[role] += 1;
s.lastPartner = partner || null;
};
const first = dates[0];
if (first) {
const windowStart = addDays(first, -historyWindowDays);
const past = Object.entries(history).filter(([date, a]) => date < first && date >= windowStart && a && !a.off).sort();
for (const [, a] of past) {
if (a.main) bump(a.main, 'main', a.co);
if (a.co) bump(a.co, 'co', a.main);
}
}
const assignedOn = (date) => {
const a = result[date] || history[date];
return a && !a.off ? [a.main, a.co].filter(Boolean) : [];
};
const previousWorkingDay = (date) => {
let d = addDays(date, -1);
while (isWeekend(d) || blocked.has(d)) d = addDays(d, -1);
return d;
};
const active = persons.filter((p) => p.active !== false);
for (const date of dates) {
const ex = existing[date];
if (ex && (ex.locked || ex.off || ex.keep)) {
const { keep, fill, ...rest } = ex;
result[date] = { ...rest };
if (!ex.off) {
if (ex.main) bump(ex.main, 'main', ex.co);
if (ex.co) bump(ex.co, 'co', ex.main);
}
continue;
}
const fixedMain = ex && ex.fill ? ex.main || null : null;
const fixedCo = ex && ex.fill ? ex.co || null : null;
const yesterday = assignedOn(previousWorkingDay(date));
const available = active.filter((p) => isAvailable(p, date, absences));
const main = fixedMain || pick(available.filter((p) => p.canMain && p.id !== fixedCo), 'main', yesterday, null, stat, rng);
const co = fixedCo || pick(available.filter((p) => p.canCo && p.id !== main), 'co', yesterday, main, stat, rng);
result[date] = { main, co, locked: false };
if (main) bump(main, 'main', co);
if (co) bump(co, 'co', main);
}
return result;
}
function pick(candidates, role, yesterday, partner, stat, rng) {
if (candidates.length === 0) return null;
let pool = candidates.filter((p) => !yesterday.includes(p.id));
if (pool.length === 0) pool = candidates;
const minTotal = Math.min(...pool.map((p) => stat(p.id).total));
pool = pool.filter((p) => stat(p.id).total === minTotal);
const minRole = Math.min(...pool.map((p) => stat(p.id)[role]));
pool = pool.filter((p) => stat(p.id)[role] === minRole);
if (partner) {
const fresh = pool.filter((p) => stat(p.id).lastPartner !== partner);
if (fresh.length > 0) pool = fresh;
}
return pool[Math.floor(rng() * pool.length)].id;
}
/** Zählt Einsätze je Person im Bereich – für die Statistik in der Oberfläche. */
export function countAssignments(assignments, from, to) {
const out = {};
for (const [date, a] of Object.entries(assignments)) {
if (date < from || date > to || !a || a.off) continue;
for (const [role, id] of [['main', a.main], ['co', a.co]]) {
if (!id) continue;
out[id] = out[id] || { main: 0, co: 0, total: 0 };
out[id][role] += 1;
out[id].total += 1;
}
}
return out;
}
+63
View File
@@ -0,0 +1,63 @@
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_STATE = () => ({
persons: [],
absences: [],
holidays: [],
assignments: {},
exported: {},
settings: {
meetingTime: '09:30',
durationMinutes: 15,
bundesland: 'BY',
calendarDisplayName: 'Morning Meeting',
inviteAttendees: false,
historyWindowDays: 60,
holidaysSeeded: false,
targetCalendarHref: '',
targetCalendarName: '',
absenceCalendarHref: '',
absenceCalendarName: '',
},
});
/** Bildtyp anhand der ersten Bytes erkennen. */
export function imageMime(buffer) {
if (!buffer || buffer.length < 12) return null;
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png';
if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
if (buffer.subarray(0, 4).toString('ascii') === 'GIF8') return 'image/gif';
return null;
}
/** Einfacher JSON-Speicher mit atomarem Schreiben. Fotos liegen daneben im Ordner photos/. */
export function createStore(dataDir) {
const file = path.join(dataDir, 'rotatory.json');
const photoDir = path.join(dataDir, 'photos');
fs.mkdirSync(photoDir, { recursive: true });
let state = DEFAULT_STATE();
if (fs.existsSync(file)) {
const loaded = JSON.parse(fs.readFileSync(file, 'utf8'));
state = { ...state, ...loaded, settings: { ...state.settings, ...(loaded.settings || {}) } };
}
function save() {
const tmp = file + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
fs.renameSync(tmp, file);
}
const photoPath = (id) => path.join(photoDir, `${id}.jpg`);
return {
get state() { return state; },
save,
photoPath,
hasPhoto: (id) => fs.existsSync(photoPath(id)),
readPhoto: (id) => (fs.existsSync(photoPath(id)) ? fs.readFileSync(photoPath(id)) : null),
savePhoto: (id, buffer) => fs.writeFileSync(photoPath(id), buffer),
deletePhoto: (id) => { try { fs.unlinkSync(photoPath(id)); } catch {} },
};
}