Tauschanfragen für Teammitglieder ergänzen

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>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-21 08:29:02 +00:00
1 parent 3aa37033fd
commit 6a3c68aaac
9 files changed
+495 -29

No files matched your search

+63
View File
@@ -6,6 +6,7 @@ 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';
@@ -59,6 +60,7 @@ app.use((req, res, 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');
};
@@ -134,6 +136,7 @@ app.get('/api/state', (req, res) => {
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,
@@ -147,6 +150,8 @@ app.get('/api/state', (req, res) => {
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,
@@ -452,6 +457,64 @@ app.get('/api/stats', (req, res) => {
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();