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:
erikandClaude Opus 5.5 committed 2026-10-30 08:23:02 +01:00
1 parent a2266662ad
commit 4c2e04e78b
19 files changed
+1100 -139

No files matched your search

+386 -15
View File
@@ -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); }
}