Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine, Abwesenheiten als .ics), einzurichten unter Einstellungen - Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt auf die Aufgabe; einzurichten unter Admin → Teams - Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei davor, im Verlauf „Weitere laden“ in Fünferschritten - Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei Meilensteinen, nur aktiv mit eingerichteter KI - Kalender zeigt die KW, auch statt der Browser-Datumsfelder - Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2266662ad
commit
4c2e04e78b
19 files changed
+1100
-139
No files matched your search
@@ -514,6 +514,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
position TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
notiz TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (week_label, project_id, milestone_id)
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||||
@@ -779,7 +780,8 @@ const RECHTE = [
|
||||
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
|
||||
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
|
||||
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
|
||||
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''] ]},
|
||||
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
|
||||
['a.teams', 'Teams-Anbindung', ''] ]},
|
||||
];
|
||||
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
|
||||
const RECHT_KEYS = new Set(RECHT_LABEL.keys());
|
||||
@@ -1224,8 +1226,8 @@ function projektStatements(){
|
||||
VALUES (?, ?, ?, ?)`);
|
||||
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
||||
VALUES (?, ?, ?, ?)`);
|
||||
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
||||
VALUES (?, ?, ?, ?)`);
|
||||
|
||||
@@ -1310,7 +1312,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
|
||||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
||||
});
|
||||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
||||
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx);
|
||||
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
|
||||
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
||||
});
|
||||
}
|
||||
@@ -1409,11 +1411,15 @@ function loadGlobalPeople(fuerUser){
|
||||
function loadWeeks(nurLabel){
|
||||
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
|
||||
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
|
||||
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
|
||||
// einige Sitzungen liefern).
|
||||
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
|
||||
if(labels && labels.length === 0) return [];
|
||||
const alle = (tabelle, reihenfolge) => {
|
||||
const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
|
||||
const wo = nurLabel === undefined ? '' : ` WHERE ${spalte} = ?`;
|
||||
const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
|
||||
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
|
||||
return nurLabel === undefined ? st.all() : st.all(String(nurLabel));
|
||||
return labels ? st.all(...labels) : st.all();
|
||||
};
|
||||
const weeks = alle('weeks', 'ORDER BY label');
|
||||
const weekPeople = alle('week_people', 'ORDER BY array_order');
|
||||
@@ -1534,7 +1540,7 @@ function loadWeeks(nurLabel){
|
||||
const msMap = {};
|
||||
milestones.forEach(r => {
|
||||
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
||||
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] };
|
||||
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
|
||||
});
|
||||
milestoneDeps.forEach(r => {
|
||||
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
||||
@@ -2671,6 +2677,11 @@ function migrateAddPlanTodoNotiz(){
|
||||
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||||
console.log('plan_todos um notiz ergänzt.');
|
||||
}
|
||||
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
|
||||
if(!msCols.includes('notiz')){
|
||||
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||||
console.log('plan_milestones um notiz ergänzt.');
|
||||
}
|
||||
}
|
||||
|
||||
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
||||
@@ -3038,6 +3049,12 @@ function migrateUsersDepartment(){
|
||||
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
|
||||
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
|
||||
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
|
||||
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
|
||||
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
|
||||
if(!cols.includes('kalender_token')){
|
||||
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
|
||||
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
|
||||
}
|
||||
if(!cols.includes('dashboard_reihenfolge')){
|
||||
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
|
||||
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
|
||||
@@ -4652,7 +4669,7 @@ app.post('/save-week', requireAuth, (req, res) => {
|
||||
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
||||
const updatedAt = saveWeekRelational(week);
|
||||
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
||||
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user);
|
||||
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
|
||||
console.log('Saved week:', week.label);
|
||||
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
|
||||
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
||||
@@ -4662,6 +4679,144 @@ app.post('/save-week', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Teams: Nachricht bei neuen Aufgaben ----------
|
||||
//
|
||||
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
|
||||
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
|
||||
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
|
||||
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
|
||||
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
|
||||
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
|
||||
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
|
||||
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
|
||||
//
|
||||
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
|
||||
// das Speichern der Sitzung scheitert nie daran.
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
|
||||
schluessel TEXT PRIMARY KEY,
|
||||
wert TEXT NOT NULL DEFAULT ''
|
||||
)`);
|
||||
|
||||
function integrationLesen(k){
|
||||
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
|
||||
return r ? r.wert : '';
|
||||
}
|
||||
function integrationSchreiben(k, v){
|
||||
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
|
||||
.run(k, String(v));
|
||||
}
|
||||
|
||||
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet.
|
||||
function teamsKarte(titel, zeilen, fakten, link, linkText){
|
||||
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
|
||||
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
|
||||
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
|
||||
const content = {
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard', version: '1.4', body,
|
||||
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
|
||||
};
|
||||
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
|
||||
}
|
||||
|
||||
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
|
||||
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
|
||||
async function teamsSenden(nachricht, url){
|
||||
const ziel = url || integrationLesen('teams_webhook');
|
||||
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
|
||||
const res = await fetchMitTimeout(ziel, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(nachricht)
|
||||
});
|
||||
if(!res.ok){
|
||||
const t = await res.text().catch(() => '');
|
||||
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
|
||||
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
|
||||
// also die Adresse, unter der die App im Büro aufgerufen wird.
|
||||
function aufgabenLink(req, weekLabel, p, todoId){
|
||||
if(!req) return '';
|
||||
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
|
||||
u.searchParams.set('weekLabel', weekLabel);
|
||||
u.searchParams.set('projNr', p.nr || '');
|
||||
u.searchParams.set('aufgabe', String(todoId));
|
||||
u.hash = 'aufgaben';
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function teamsDatum(iso){
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
|
||||
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
|
||||
}
|
||||
|
||||
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
|
||||
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
|
||||
function teamsNeueAufgaben(weekLabel, neu, user, req){
|
||||
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
|
||||
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
|
||||
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
|
||||
const von = (user && (user.displayName || user.username)) || '';
|
||||
neu.forEach(({ p, t, personen }) => {
|
||||
const wer = personen.map(id => namen.get(id) || id).join(', ');
|
||||
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
|
||||
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')], ['Zuständig', wer]];
|
||||
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
|
||||
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
|
||||
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
|
||||
if(von) fakten.push(['Zugewiesen von', von]);
|
||||
const karte = teamsKarte('Neue Aufgabe: ' + (t.title || 'Aufgabe'), [], fakten,
|
||||
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
|
||||
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message));
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||||
const url = integrationLesen('teams_webhook');
|
||||
let host = '';
|
||||
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
|
||||
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
|
||||
});
|
||||
|
||||
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
|
||||
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
if(typeof b.webhook === 'string'){
|
||||
const w = b.webhook.trim();
|
||||
if(w){
|
||||
let u;
|
||||
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
|
||||
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
|
||||
}
|
||||
integrationSchreiben('teams_webhook', w);
|
||||
}
|
||||
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
|
||||
res.json({ ok: true });
|
||||
} catch(err){
|
||||
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
|
||||
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
|
||||
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
|
||||
try {
|
||||
const link = req.protocol + '://' + req.get('host') + '/';
|
||||
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden',
|
||||
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
|
||||
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen'));
|
||||
res.json({ ok: true });
|
||||
} catch(err){
|
||||
res.json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
|
||||
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
|
||||
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
|
||||
@@ -4669,7 +4824,7 @@ app.post('/save-week', requireAuth, (req, res) => {
|
||||
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
|
||||
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
|
||||
// vor dem Speichern), user (req.user). Keine Rückgabe.
|
||||
function neueZuweisungenMelden(week, alt, user){
|
||||
function neueZuweisungenMelden(week, alt, user, req){
|
||||
try {
|
||||
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
|
||||
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
|
||||
@@ -4679,12 +4834,21 @@ function neueZuweisungenMelden(week, alt, user){
|
||||
const jetzt = new Date().toISOString();
|
||||
const von = (user && (user.displayName || user.username)) || '';
|
||||
let anzahl = 0;
|
||||
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
|
||||
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
|
||||
const fuerTeams = new Map();
|
||||
const sandbox = week.label === SANDBOX_WEEK_LABEL;
|
||||
const ichPerson = user && user.personId ? String(user.personId) : '';
|
||||
(Array.isArray(week.projects) ? week.projects : []).forEach(p => {
|
||||
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
|
||||
todos.forEach(t => {
|
||||
if(!t || t.done) return;
|
||||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
|
||||
if(alt.has(String(t.id) + '|' + String(pid))) return;
|
||||
if(!sandbox && !t.lph && String(pid) !== ichPerson){
|
||||
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
|
||||
fuerTeams.get(String(t.id)).personen.push(String(pid));
|
||||
}
|
||||
const k = konto.get(String(pid));
|
||||
if(!k || (user && k.id === user.id)) return;
|
||||
if(schonDa.get(k.id, String(t.id))) return;
|
||||
@@ -4695,6 +4859,7 @@ function neueZuweisungenMelden(week, alt, user){
|
||||
});
|
||||
});
|
||||
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
|
||||
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
|
||||
} catch(err){
|
||||
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
|
||||
}
|
||||
@@ -4897,7 +5062,7 @@ app.post('/save-projects', requireAuth, (req, res) => {
|
||||
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
|
||||
db.exec('COMMIT');
|
||||
} catch(err){ db.exec('ROLLBACK'); throw err; }
|
||||
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user);
|
||||
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
|
||||
const ids = new Set(liste.map(e => String(e.project.id)));
|
||||
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
|
||||
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
|
||||
@@ -4915,6 +5080,25 @@ app.post('/save-projects', requireAuth, (req, res) => {
|
||||
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
|
||||
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
|
||||
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
|
||||
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
|
||||
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
|
||||
// noch übrig sind.
|
||||
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
|
||||
try {
|
||||
const liste = echteSitzungenSortiert();
|
||||
const vor = String(req.query.vor || '');
|
||||
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
|
||||
const idx = liste.findIndex(w => w.label === vor);
|
||||
const bis = idx < 0 ? liste.length : idx;
|
||||
const von = Math.max(0, bis - anzahl);
|
||||
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
|
||||
res.json({ ok: true, weeks, uebrig: von });
|
||||
} catch(err){
|
||||
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
|
||||
try {
|
||||
const revisionen = {};
|
||||
@@ -5017,7 +5201,8 @@ function projektlisteAenderungen(week){
|
||||
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
|
||||
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
|
||||
if(!fehlend.length) return { neu, entfernt: [] };
|
||||
const w = loadWeeks().find(x => x.label === label);
|
||||
// Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
|
||||
const w = loadWeeks(label)[0];
|
||||
const fehlendIds = new Set(fehlend.map(r => r.project_id));
|
||||
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
|
||||
}
|
||||
@@ -5176,9 +5361,25 @@ app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
|
||||
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
|
||||
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
|
||||
// relationalen Tabellen zusammengesetzt); nach Label sortiert.
|
||||
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
|
||||
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
|
||||
function echteSitzungenSortiert(){
|
||||
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||||
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
|
||||
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||||
}
|
||||
|
||||
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
|
||||
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
|
||||
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
|
||||
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
|
||||
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
||||
try {
|
||||
const allWeeks = loadWeeks();
|
||||
const anzahl = Math.floor(Number(req.query.wochen));
|
||||
const wochenListe = echteSitzungenSortiert();
|
||||
const allWeeks = anzahl > 0
|
||||
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
|
||||
: loadWeeks();
|
||||
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
|
||||
// ist keine echte Sitzung — sie wird separat als sandboxPlans
|
||||
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
|
||||
@@ -5189,7 +5390,7 @@ app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
||||
const sandboxPlans = sandbox ? sandbox.projects : [];
|
||||
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
|
||||
const people = loadGlobalPeople(req.user);
|
||||
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt });
|
||||
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
|
||||
} catch(err){
|
||||
console.error('Load error', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
@@ -6927,6 +7128,7 @@ const KI_MAIL_SCHEMA = {
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
|
||||
titel: { type: 'string' },
|
||||
start: { type: 'string' },
|
||||
ende: { type: 'string' },
|
||||
@@ -6934,7 +7136,7 @@ const KI_MAIL_SCHEMA = {
|
||||
aufwandStunden: { type: 'number' },
|
||||
zitat: { type: 'string' }
|
||||
},
|
||||
required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
|
||||
required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
@@ -6947,7 +7149,8 @@ const KI_MAIL_SCHEMA = {
|
||||
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
|
||||
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
|
||||
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
|
||||
+ 'Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
|
||||
+ 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
|
||||
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
|
||||
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
|
||||
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
|
||||
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
|
||||
@@ -6976,6 +7179,7 @@ function normalisiereMailEintrag(e){
|
||||
if(ende && !start) start = ende;
|
||||
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
|
||||
return {
|
||||
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
|
||||
titel: String((e && e.titel) || '').trim().slice(0, 200),
|
||||
start, ende,
|
||||
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
|
||||
@@ -8265,6 +8469,169 @@ app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
|
||||
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
|
||||
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
|
||||
// apiUrl zum Weiterbearbeiten.
|
||||
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
|
||||
//
|
||||
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
|
||||
// unter Einstellungen → Mein Konto eine geheime Adresse
|
||||
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
|
||||
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
|
||||
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
|
||||
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
|
||||
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
|
||||
// keine Zeitzone im Spiel ist.
|
||||
//
|
||||
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
|
||||
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
|
||||
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
|
||||
|
||||
function kalenderUrl(req, token){
|
||||
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
|
||||
}
|
||||
|
||||
app.get('/mein-kalender', requireAuth, (req, res) => {
|
||||
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
|
||||
const token = row && row.kalender_token;
|
||||
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||||
});
|
||||
|
||||
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
|
||||
// bisherige wird damit ungültig).
|
||||
app.post('/mein-kalender', requireAuth, (req, res) => {
|
||||
try {
|
||||
const aus = !!(req.body && req.body.aus);
|
||||
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
|
||||
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||||
} catch(err){
|
||||
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// icsText: Text nach RFC 5545 maskieren.
|
||||
function icsText(s){
|
||||
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
|
||||
function icsZeile(z){
|
||||
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
|
||||
const teile = [];
|
||||
let rest = z;
|
||||
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
|
||||
const grenze = () => teile.length ? 74 : 75;
|
||||
while(Buffer.byteLength(rest, 'utf8') > grenze()){
|
||||
let n = Math.min(rest.length, grenze());
|
||||
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
|
||||
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
|
||||
const c = rest.charCodeAt(n - 1);
|
||||
if(c >= 0xD800 && c <= 0xDBFF) n--;
|
||||
teile.push(rest.slice(0, n));
|
||||
rest = rest.slice(n);
|
||||
}
|
||||
teile.push(rest);
|
||||
return teile.join('\r\n ');
|
||||
}
|
||||
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
|
||||
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
|
||||
function icsTagDanach(iso){
|
||||
const d = new Date(iso + 'T12:00:00');
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
|
||||
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
|
||||
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
|
||||
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
|
||||
function kalenderEreignisse(ich, w, abwesenheiten){
|
||||
const ereignisse = [];
|
||||
if(w && ich){
|
||||
(w.projects || []).forEach(p => {
|
||||
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
|
||||
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
|
||||
const plan = p.plan || {};
|
||||
let eigeneAufgabe = false;
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
|
||||
eigeneAufgabe = true;
|
||||
if(t.done || t.lph) return;
|
||||
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
|
||||
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
|
||||
if(!start) return;
|
||||
ereignisse.push({
|
||||
uid: 'aufgabe-' + t.id, start, ende,
|
||||
titel: t.title || 'Aufgabe', projekt,
|
||||
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
|
||||
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
|
||||
});
|
||||
});
|
||||
if(!zugeordnet && !eigeneAufgabe) return;
|
||||
(plan.milestones || []).forEach(m => {
|
||||
if(!ICS_DATUM.test(m.date || '')) return;
|
||||
ereignisse.push({
|
||||
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
|
||||
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
|
||||
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
(abwesenheiten || []).forEach(a => {
|
||||
if(!ICS_DATUM.test(a.start || '')) return;
|
||||
ereignisse.push({
|
||||
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
|
||||
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
|
||||
});
|
||||
});
|
||||
return ereignisse;
|
||||
}
|
||||
|
||||
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
|
||||
function kalenderText(name, ereignisse){
|
||||
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
const zeilen = [
|
||||
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
|
||||
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
|
||||
];
|
||||
ereignisse.forEach(e => {
|
||||
zeilen.push('BEGIN:VEVENT',
|
||||
'UID:' + icsText(e.uid) + '@projektuebersicht',
|
||||
'DTSTAMP:' + jetzt,
|
||||
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
|
||||
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
|
||||
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
|
||||
'DESCRIPTION:' + icsText(e.text),
|
||||
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
|
||||
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
|
||||
'TRANSP:TRANSPARENT',
|
||||
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
|
||||
'END:VEVENT');
|
||||
});
|
||||
zeilen.push('END:VCALENDAR');
|
||||
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
app.get('/kalender/:datei', (req, res) => {
|
||||
try {
|
||||
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
|
||||
if(token.length < 20) return res.status(404).end();
|
||||
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
|
||||
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
|
||||
const label = echteSitzungenSortiert().map(w => w.label).pop();
|
||||
const w = label ? loadWeeks(label)[0] : null;
|
||||
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
|
||||
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
|
||||
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
|
||||
} catch(err){
|
||||
console.error('Kalender ausliefern fehlgeschlagen:', err);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/my-ai-settings', requireAuth, (req, res) => {
|
||||
try {
|
||||
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
|
||||
@@ -8879,6 +9246,10 @@ function betriebAufraeumen(){
|
||||
try {
|
||||
const grenze = Date.now() - loginBlockMs();
|
||||
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
|
||||
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
|
||||
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
|
||||
const stundeHer = Date.now() - 60 * 60 * 1000;
|
||||
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
|
||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user