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:
1 parent
3aa37033fd
commit
6a3c68aaac
9 files changed
+495
-29
No files matched your search
@@ -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();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Tauschanfragen: Betrachter melden, dass sie an einem Tag nicht können; Bearbeiter führen den Tausch aus.
|
||||
import { isValidDate } from './scheduler.js';
|
||||
|
||||
export const REQUEST_STATUS = ['offen', 'erledigt', 'abgelehnt'];
|
||||
|
||||
/** Rolle einer Person an einem Tag: 'main', 'co' oder null. */
|
||||
export function roleOn(assignment, personId) {
|
||||
if (!assignment || assignment.off || !personId) return null;
|
||||
if (assignment.main === personId) return 'main';
|
||||
if (assignment.co === personId) return 'co';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe einer Anfrage prüfen. persons: [{id, active}], assignments: { date: {main, co, off} }.
|
||||
* Liefert { error } oder { request } (ohne id/status/createdAt).
|
||||
*/
|
||||
export function validateRequest(body, persons, assignments) {
|
||||
const b = body || {};
|
||||
const active = (id) => persons.find((p) => p.id === id && p.active !== false);
|
||||
const personId = String(b.personId || '');
|
||||
if (!active(personId)) return { error: 'Bitte wähle deinen Namen.' };
|
||||
const date = String(b.date || '');
|
||||
if (!isValidDate(date)) return { error: 'Ungültiger Tag.' };
|
||||
const role = roleOn(assignments[date], personId);
|
||||
if (!role) return { error: 'An diesem Tag bist du laut Plan nicht eingeteilt.' };
|
||||
const partnerId = b.partnerId ? String(b.partnerId) : null;
|
||||
if (partnerId && !active(partnerId)) return { error: 'Tauschpartner nicht gefunden.' };
|
||||
if (partnerId === personId) return { error: 'Mit dir selbst kannst du nicht tauschen.' };
|
||||
const partnerDate = b.partnerDate ? String(b.partnerDate) : null;
|
||||
if (partnerDate && !isValidDate(partnerDate)) return { error: 'Ungültiger Wunschtag.' };
|
||||
if (partnerDate && !partnerId) return { error: 'Zum Wunschtag bitte auch die Person angeben, mit der du tauschen möchtest.' };
|
||||
if (partnerDate === date) return { error: 'Der Wunschtag ist derselbe Tag.' };
|
||||
const note = String(b.note || '').trim().slice(0, 300);
|
||||
return { request: { personId, date, role, partnerId, partnerDate, note } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tausch ausführen. Liefert { changes: [{date, role, from, to}] } und verändert assignments direkt.
|
||||
* Fälle:
|
||||
* - Partner + Wunschtag: über Kreuz – Partner übernimmt den Tag des Anfragenden, dieser den Slot des Partners am Wunschtag.
|
||||
* - Nur Partner: Partner übernimmt den Slot des Anfragenden.
|
||||
* - Ohne Partner: nicht automatisch ausführbar.
|
||||
*/
|
||||
export function applySwap(assignments, req) {
|
||||
const a = assignments[req.date];
|
||||
const role = roleOn(a, req.personId);
|
||||
if (!role) throw new Error('Die Person ist an diesem Tag nicht mehr eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||
if (!req.partnerId) throw new Error('Ohne Wunschpartner bitte den Tag öffnen und selbst jemanden auswählen.');
|
||||
const otherRole = role === 'main' ? 'co' : 'main';
|
||||
if (a[otherRole] === req.partnerId) throw new Error('Der Wunschpartner ist an diesem Tag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||
const changes = [];
|
||||
if (req.partnerDate) {
|
||||
const b = assignments[req.partnerDate];
|
||||
const partnerRole = roleOn(b, req.partnerId);
|
||||
if (!partnerRole) throw new Error('Der Wunschpartner ist am Wunschtag nicht eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||
const otherB = partnerRole === 'main' ? 'co' : 'main';
|
||||
if (b[otherB] === req.personId) throw new Error('Die anfragende Person ist am Wunschtag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||
b[partnerRole] = req.personId;
|
||||
changes.push({ date: req.partnerDate, role: partnerRole, from: req.partnerId, to: req.personId });
|
||||
}
|
||||
a[role] = req.partnerId;
|
||||
changes.push({ date: req.date, role, from: req.personId, to: req.partnerId });
|
||||
return { changes };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const DEFAULT_STATE = () => ({
|
||||
absences: [],
|
||||
holidays: [],
|
||||
assignments: {},
|
||||
requests: [],
|
||||
exported: {},
|
||||
settings: {
|
||||
meetingTime: '09:30',
|
||||
|
||||
Reference in new issue
Block a user