Files
dogfather-universe/server/workspace-ics.js
T
DogFatherGitandClaude Opus 5 233cd76d78 Der Handy-Durchgang: drei echte Bedienfehler, alle vom selben Ursprung
Filipe: "ich will dass du die komplette seite auf dem handy abcheckst.
ich will dass alles perfekt aussieht und bedienbar ist."

DER SCHWERSTE FUND: Auf ZWOELF Seiten war der "Meine Sicht"-Umschalter
nicht bedienbar -- wer darauf tippte, landete im Chat.

Bei 412 px (der haeufigsten Android-Breite ueberhaupt) brach die
Kopfleiste nicht um, der Umschalter wurde auf 50 px zusammengedrueckt,
sein Knopf behielt aber seine 104 px Mindestbreite und lag damit quer
ueber dem Chat-Knopf. Sichtbar war davon nichts.

Und die Ursache steht seit dem 05.09. woertlich im Kommentar daneben:
"Die Rechnung ging genau auf, solange rechts VIER Dinge standen. Mit der
Glocke sind es FUENF, und bei 320 px passte es nicht mehr." Am 06.09.
habe ich den Chat-Knopf dazugesetzt -- SECHS -- und die Schwelle bei 380
gelassen. Derselbe Fehler, eine Position weiter.

Die Lehre ist nicht "380 auf 430 erhoehen"; das waere er ein drittes
Mal, nur mit einer anderen Zahl. Eine feste Schwelle ist eine Rechnung,
die jemand einmal aufgestellt hat und die beim naechsten Knopf still
falsch wird. `flex-wrap: wrap` OHNE Schwelle rechnet nicht, sondern
misst -- es bricht genau dann um, wenn der Platz nicht reicht.

DASSELBE NOCH EINMAL, am anderen Ende: Bei 1280 px brauchte die Leiste
1235 px (Marke 375 + Bedienelemente 844 + Abstand 16) und hatte 1200.
Auch hier war der Chat-Knopf der Tropfen. Sie brach um, sobald das
Skript die Bedienelemente eingehaengt hatte, und schob die ganze Seite
52 px nach unten -- CLS 0,94. Jetzt gibt die MARKE nach (sie darf
gekuerzt werden, ein Knopf nicht), und umgebrochen wird nur noch auf
sehr schmalen Geraeten.

WEITERE ECHTE FUNDE:
  * /workspace/api/chat/ungelesen wurde auf JEDER Seite ZWEIMAL geholt:
    einmal beim Laden, einmal Millisekunden spaeter beim Aufgehen des
    Ereignisstroms. Der 'open'-Zuhoerer war fuer Wiederverbindungen
    gedacht und feuerte auch beim ersten Mal.
  * Drei Kacheln teilten sich einen Farbton mit einer anderen (18 Farben
    auf 21 Kacheln). Filipe wollte ausdruecklich, dass jede ihre eigene
    hat. Nicht eine Farbe dazuerfunden -- der ganze Farbkreis ist mit
    tools/kachel-farben.mjs neu in 21 geteilt; kleinster Abstand zweier
    Nachbarn jetzt 120 Grad (vorher 106). Dabei fiel eine feste 16 in
    der Mischschleife auf: Bei 18 Kacheln wurden die letzten beiden nie
    mitgemischt.
  * Der Agentur-Untertitel wurde auf dem Handy abgeschnitten.
  * Ein zugeklappter <details>-Kasten (Kalender-Abo) verdeckte einen
    Filter-Chip: Chromium versteckt dessen Inhalt ueber
    `content-visibility`, nicht ueber `display` -- die Kaesten behalten
    eine Groesse. Dieselbe Falle wie bei den Zeitbloecken, dieselbe
    Loesung.

UND DREI PRUEFUNGEN, DIE SELBST FALSCH LAGEN:
  * "achtzehn Kacheln" stand als feste Zahl im Test. Eine Pruefung, die
    bei jeder neuen Kachel rot wird, erzieht dazu, ihr Rotwerden zu
    ignorieren. Sie zaehlt jetzt aus bereiche.js.
  * "das Licht der Kalender-Kachel (tuerkis) muss mehr Blau als Rot
    haben" -- Wissen von aussen, und nach dem Neurechnen war der
    Kalender rosa. Gemessen wird jetzt gegen den Ton der Kachel selbst.
  * pruef-workspace-umzug meldete sein Ergebnis in eigenen Worten. Der
    Gesamtlauf las daraus NULL Pruefungen und schrieb bei JEDEM Lauf ein
    FEHL, obwohl alle 31 Punkte bestanden. Ein Fehlalarm, der immer
    kommt, macht den einen echten unsichtbar.

Und weil "BUTTON 'Meine Sicht' verdeckt" mich eine Stunde gekostet hat,
sagen pruef-handy und pruef-tempo jetzt DAZU, was verdeckt und was
springt -- mit Elternkette und Koordinaten. Ein Befund, den man nicht
verorten kann, ist ein halber.

NEU: der Kalender zum Abonnieren (Stufe 3.2). Persoenlicher, jederzeit
widerrufbarer Link fuer Google, Apple und Outlook. 36 Pruefungen, die
das ICS ZURUECKLESEN statt es anzusehen -- entfaltet, entschluesselt,
verglichen. Wichtigster Punkt: Der Weg ohne Anmeldung darf nichts
zeigen, was der Weg mit Anmeldung nicht zeigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 23:15:37 +02:00

441 lines
18 KiB
JavaScript

/* =====================================================================
workspace-ics.js — der eigene Kalender zum Abonnieren.
Stufe 3.2 des Plans vom 31.08.2026.
---------------------------------------------------------------------
WARUM DAS MEHR WERT IST ALS NOCH EINE ERINNERUNG
Der Workspace hat seit dem 05.09. Web Push. Trotzdem bleibt ein
Problem, das keine Benachrichtigung loest: Die Termine stehen in
EINEM Kalender, und alle anderen Kalender im Leben eines Menschen
wissen nichts davon. Wer am Dienstag um drei einen Zahnarzttermin
eintraegt, sieht nicht, dass da schon ein Call steht.
Ein abonnierter Kalender dreht das um: Die Termine kommen dorthin, wo
ohnehin nachgesehen wird. Einmal eingerichtet, nie wieder anfassen.
---------------------------------------------------------------------
DIE DREI ENTSCHEIDUNGEN
1. EIN GEHEIMER LINK, KEIN LOGIN.
Google, Apple und Outlook holen den Kalender im Hintergrund ab --
ohne Browser, ohne Sitzung, ohne Anmeldemaske. Ein Kalender, der
eine Anmeldung verlangt, laesst sich schlicht nicht abonnieren.
Der Schluessel IST deshalb der Zugang, und er ist entsprechend
lang (32 Byte Zufall).
2. DER LINK MUSS WIDERRUFBAR SEIN.
Ein Geheimnis, das man nicht zuruecknehmen kann, ist keins. Wer
seinen Link versehentlich weitergibt, drueckt auf "Neuen Link
erzeugen" -- der alte ist im selben Moment tot. Deshalb steht der
Schluessel in einer eigenen Tabelle mit genau einer Zeile je
Person und wird beim Erneuern ueberschrieben.
3. NUR LESEN, NUR EIGENES.
Der Link kann nichts aendern und nichts anderes zeigen als die
Termine, die diese Person auch im Workspace sieht -- dieselbe
Regel (termineSichtbar), nicht eine zweite daneben. Zwei
Sichtbarkeitsregeln laufen mit der Zeit auseinander, und dann
zeigt ausgerechnet der Weg ohne Anmeldung mehr als der mit.
---------------------------------------------------------------------
WAS BEWUSST NICHT DRIN STEHT
Interne Notizen, Bewertungen, Protokolle. Ein abonnierter Kalender
landet auf Geraeten, ueber die wir nichts wissen -- auf dem Handy
eines Creators, in einem Google-Konto, vielleicht auf einem
Familien-Tablet. Dort gehoert hin, WANN etwas ist und MIT WEM, mehr
nicht. Die Beschreibung wird deshalb gekuerzt und enthaelt keinen
internen Vermerk.
===================================================================== */
import express from "express";
import { randomBytes, timingSafeEqual } from "node:crypto";
import {
db, protokolliere, echteIp, sitzungLesen, termineSichtbar, externSql,
} from "./workspace.js";
export const icsRouter = express.Router();
const jetzt = () => new Date().toISOString();
/* ---------- Der Schluessel --------------------------------------------- */
/** Legt die Tabelle an. Eine Zeile je Person -- mehr braucht es nicht,
* und "genau eine" macht das Widerrufen zu einem Ueberschreiben statt
* zu einem Aufraeumen. */
function tabelle() {
db().exec(`
CREATE TABLE IF NOT EXISTS ics_schluessel (
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
schluessel TEXT NOT NULL UNIQUE,
erstellt TEXT NOT NULL,
zuletzt TEXT
)`);
}
/** Holt den Schluessel dieser Person -- oder legt einen an.
* `neu = true` erzwingt einen frischen; der alte ist danach tot. */
export function schluesselFuer(personId, { neu = false } = {}) {
tabelle();
const d = db();
if (!neu) {
const da = d.prepare("SELECT schluessel FROM ics_schluessel WHERE person_id = ?")
.get(personId);
if (da) return da.schluessel;
}
/* 32 Byte aus randomBytes, als Basis64url. Das ist der ganze Schutz
dieses Weges -- entsprechend lang. Kein Zeitstempel und keine
Personennummer darin: Beides waere ratbar. */
const s = randomBytes(32).toString("base64url");
d.prepare(`
INSERT INTO ics_schluessel (person_id, schluessel, erstellt)
VALUES (?,?,?)
ON CONFLICT(person_id) DO UPDATE SET schluessel = excluded.schluessel,
erstellt = excluded.erstellt,
zuletzt = NULL`)
.run(personId, s, jetzt());
return s;
}
/** Wem gehoert dieser Schluessel?
*
* VERGLICHEN WIRD IN GLEICHBLEIBENDER ZEIT. Ein gewoehnlicher Vergleich
* bricht beim ersten falschen Zeichen ab; wer die Antwortzeit misst,
* kann daraus Zeichen fuer Zeichen den Schluessel erraten. Bei einem
* Geheimnis, das ohne Anmeldung gilt, ist das kein theoretischer
* Einwand.
*
* Die Suche selbst laeuft ueber den Index (UNIQUE) -- deshalb wird
* danach NOCH EINMAL Byte fuer Byte verglichen, damit das Ergebnis
* nicht allein von der Datenbank abhaengt. */
function personZu(schluessel) {
tabelle();
const s = String(schluessel || "");
if (s.length < 40 || s.length > 64 || !/^[A-Za-z0-9_-]+$/.test(s)) return null;
const z = db().prepare(`
SELECT k.person_id, k.schluessel, p.name, p.rolle, p.aktiv
FROM ics_schluessel k JOIN personen p ON p.id = k.person_id
WHERE k.schluessel = ?`).get(s);
if (!z || !z.aktiv) return null;
const a = Buffer.from(z.schluessel, "utf8");
const b = Buffer.from(s, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
return { id: z.person_id, name: z.name, rolle: z.rolle };
}
/* ---------- ICS bauen ---------------------------------------------------- */
/** Text so verpacken, wie RFC 5545 es verlangt.
*
* Backslash, Semikolon, Komma und Zeilenumbruch haben im Format eine
* Bedeutung. Ein Titel wie "Call: Ziele, Technik" wuerde ohne das hier
* am Komma auseinanderfallen -- und zwar so, dass der Kalender
* entweder Unsinn anzeigt oder die ganze Datei verwirft. */
function text(roh) {
return String(roh ?? "")
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r?\n/g, "\\n");
}
/** Zeilen auf 75 Oktette falten.
*
* Das Format schreibt es vor, und Outlook nimmt es ernst: Eine lange
* Zeile wird dort abgeschnitten, nicht umgebrochen -- der Termin
* verliert dann seinen halben Titel.
*
* Gezaehlt werden BYTES, nicht Zeichen. Ein Umlaut ist in UTF-8 zwei
* Byte; wer Zeichen zaehlt, ueberschreitet die Grenze bei deutschen
* Titeln regelmaessig. Und es wird nie MITTEN in ein Zeichen
* geschnitten -- sonst steht dort ein Fragezeichen. */
function falten(zeile) {
const bytes = Buffer.from(zeile, "utf8");
if (bytes.length <= 75) return zeile;
const stuecke = [];
let ab = 0;
let grenze = 75;
while (ab < bytes.length) {
let bis = Math.min(ab + grenze, bytes.length);
/* Nicht in ein Mehrbyte-Zeichen hineinschneiden: Fortsetzungsbytes
beginnen mit 10xxxxxx. */
while (bis > ab && bis < bytes.length && (bytes[bis] & 0xc0) === 0x80) bis--;
stuecke.push(bytes.subarray(ab, bis).toString("utf8"));
ab = bis;
grenze = 74; // Folgezeilen beginnen mit einem Leerzeichen
}
return stuecke.join("\r\n ");
}
/** "2026-09-08T14:30" (Ortszeit) -> "20260908T143000".
*
* Die Termine stehen in der Datenbank als ORTSZEIT ohne Zone. Genau so
* gehen sie hier hinaus, zusammen mit TZID=Europe/Berlin -- die
* Umrechnung macht der Kalender des Empfaengers. Wer hier selbst nach
* UTC rechnet, muss die Sommerzeit nachbilden und liegt zweimal im
* Jahr eine Stunde daneben. */
function stempel(beginn) {
const t = String(beginn || "").replace(" ", "T");
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}00`;
}
const ART_NAME = { call: "Call", termin: "Termin", review: "Review", frist: "Frist" };
/** Die Zeitzone als eigener Block.
*
* Ohne ihn kennt ein Kalender "Europe/Berlin" zwar meist trotzdem --
* aber "meist" ist bei Terminen zu wenig. Outlook zeigt Termine sonst
* je nach Fassung eine Stunde daneben, und das faellt erst auf, wenn
* jemand zu spaet kommt. */
const ZEITZONE = [
"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",
];
export function icsBauen(person, termine, gastgeber) {
const zeilen = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Dogfather Universe//Creator Workspace//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
`X-WR-CALNAME:${text("Creator Workspace")}`,
"X-WR-TIMEZONE:Europe/Berlin",
/* Wie oft der Kalender nachsehen soll. Eine Stunde ist der
Kompromiss: Google haelt sich ohnehin selten daran, Apple schon.
Kuerzer waere Hoeflichkeit gegenueber dem eigenen Server. */
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
"X-PUBLISHED-TTL:PT1H",
...ZEITZONE,
];
const jetztStempel = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";
for (const t of termine) {
const start = stempel(t.beginn);
if (!start) continue; // unlesbares Datum: lieber weglassen
const dauer = Math.max(5, Math.min(Number(t.dauer_min) || 30, 1440));
/* Die Kennung muss ueber Jahre DIESELBE bleiben. Aendert sie sich,
loescht der Kalender den alten Eintrag und legt einen neuen an --
aus einem verschobenen Termin werden dann zwei, und die
Erinnerung des Nutzers haengt an der falschen Haelfte. Deshalb
aus der Datenbanknummer gebildet und aus nichts sonst. */
zeilen.push("BEGIN:VEVENT");
zeilen.push(`UID:termin-${t.id}@workspace.dogfather-universe.com`);
zeilen.push(`DTSTAMP:${jetztStempel}`);
zeilen.push(`DTSTART;TZID=Europe/Berlin:${start}`);
zeilen.push(`DURATION:PT${dauer}M`);
const art = ART_NAME[t.art] || "Termin";
const mit = t.teilnehmer_name || t.creator_name || null;
zeilen.push(falten(`SUMMARY:${text(`${art}: ${t.titel}`)}`));
/* NUR WANN UND MIT WEM. Interne Notizen bleiben draussen -- dieser
Kalender landet auf Geraeten, ueber die wir nichts wissen. */
const teile = [];
if (mit) teile.push(`Mit: ${mit}`);
if (t.beschreibung) teile.push(String(t.beschreibung).slice(0, 500));
teile.push(`Im Workspace: ${gastgeber}/workspace/kalender.html`);
zeilen.push(falten(`DESCRIPTION:${text(teile.join("\n"))}`));
if (t.ort) zeilen.push(falten(`LOCATION:${text(t.ort)}`));
/* Ein Link im eigenen Feld -- Apple und Google machen daraus einen
Knopf "Beitreten". */
if (t.ort && /^https?:\/\//i.test(String(t.ort))) {
zeilen.push(falten(`URL:${text(t.ort)}`));
}
/* Erledigte Termine bleiben drin, aber als abgesagt gekennzeichnet:
Sie einfach verschwinden zu lassen hiesse, dass ein Kalender, der
gestern noch etwas zeigte, heute schweigt -- und niemand weiss,
ob der Termin weg ist oder die Anbindung kaputt. */
zeilen.push(`STATUS:${t.erledigt ? "CANCELLED" : "CONFIRMED"}`);
/* Eine Erinnerung 15 Minuten vorher. Der Kalender kann sie
ueberschreiben; ohne sie gaebe es gar keine. */
if (!t.erledigt) {
zeilen.push("BEGIN:VALARM");
zeilen.push("ACTION:DISPLAY");
zeilen.push("TRIGGER:-PT15M");
zeilen.push(falten(`DESCRIPTION:${text(`${art}: ${t.titel}`)}`));
zeilen.push("END:VALARM");
}
zeilen.push("END:VEVENT");
}
zeilen.push("END:VCALENDAR");
/* CRLF, nicht LF. Das Format schreibt es vor, und Outlook verwirft
eine Datei mit blossen Zeilenvorschueben wortlos. */
return zeilen.join("\r\n") + "\r\n";
}
/* =====================================================================
Die Wege
===================================================================== */
const SPALTEN = `
t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort,
t.erledigt,
${externSql("pc.name", "t.teilnehmer_extern")} AS creator_name,
${externSql("pt.name", "t.teilnehmer_extern")} AS teilnehmer_name`;
const VERBUND = `
FROM termine t
LEFT JOIN personen pc ON pc.id = t.creator_id
LEFT JOIN personen pt ON pt.id = t.teilnehmer_id`;
/** Der abonnierbare Kalender. OHNE Anmeldung, mit Schluessel. */
/* DER PFAD ENDET AUF .ics -- viele Kalenderprogramme bestehen darauf.
Die Endung wird hier aber SELBST abgeschnitten und nicht dem
Wegemuster ueberlassen (`:schluessel.ics`). Wie Express einen Punkt
in einem Muster behandelt, haengt an der Fassung seiner
Wegebibliothek -- und ein Weg, der nach einem Update still nicht mehr
trifft, waere hier besonders unangenehm: Der Kalender hoert einfach
auf, sich zu aktualisieren, und niemand bekommt eine Meldung. */
icsRouter.get("/workspace/kalender/:datei", (req, res) => {
try {
const datei = String(req.params.datei || "");
if (!datei.endsWith(".ics")) {
return res.status(404).type("text/plain").send("Nicht gefunden.");
}
const person = personZu(datei.slice(0, -4));
if (!person) {
/* 404, nicht 401: Was es fuer diesen Schluessel nicht gibt, soll
auch nicht bestaetigen, dass es die Adresse gibt. */
return res.status(404).type("text/plain").send("Nicht gefunden.");
}
/* DIESELBE Sichtbarkeitsregel wie im Workspace. Eine zweite hier
liefe mit der Zeit auseinander -- und dann zeigte ausgerechnet
der Weg ohne Anmeldung mehr als der mit. */
const { wo, werte } = termineSichtbar(person, "t");
/* Ein Jahr rueckwaerts, ein Jahr vorwaerts. Alles auszuliefern
waere bei jedem Abruf die ganze Geschichte; ein Kalender braucht
sie nicht, und die Datei waechst sonst ohne Ende. */
const heute = new Date();
const vor = new Date(heute.getTime() - 365 * 86400000).toISOString().slice(0, 10);
const nach = new Date(heute.getTime() + 365 * 86400000).toISOString().slice(0, 10);
const termine = db().prepare(`
SELECT ${SPALTEN} ${VERBUND}
WHERE ${wo} AND t.beginn >= ? AND t.beginn <= ?
ORDER BY t.beginn`).all(...werte, vor, nach + "T23:59:59");
const gastgeber = `${req.protocol}://${req.get("host")}`;
const ics = icsBauen(person, termine, gastgeber);
db().prepare("UPDATE ics_schluessel SET zuletzt = ? WHERE person_id = ?")
.run(jetzt(), person.id);
res.set("Content-Type", "text/calendar; charset=utf-8");
res.set("Content-Disposition", 'inline; filename="creator-workspace.ics"');
/* Nicht zwischenspeichern: Ein Kalender, der eine alte Fassung
zeigt, ist schlimmer als keiner -- man glaubt ihm ja. */
res.set("Cache-Control", "no-store");
/* Und nicht in einer fremden Seite einbetten lassen. */
res.set("X-Content-Type-Options", "nosniff");
res.send(ics);
} catch (fehler) {
console.error("[ics] Ausliefern:", fehler?.message);
res.status(503).type("text/plain").send("Gerade nicht verfuegbar.");
}
});
/* ---- Die Verwaltung des eigenen Links (mit Anmeldung) ------------------- */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
/** Der eigene Link. JEDE Rolle hat einen -- es sind die eigenen Termine. */
icsRouter.get("/workspace/api/ics", angemeldet, (req, res) => {
try {
tabelle();
const da = db().prepare(
"SELECT schluessel, erstellt, zuletzt FROM ics_schluessel WHERE person_id = ?")
.get(req.person.id);
const gastgeber = `${req.protocol}://${req.get("host")}`;
res.json({
/* Der Link wird NICHT von selbst angelegt. Ein Geheimnis, das
entsteht, ohne dass jemand es wollte, liegt danach herum --
angelegt wird er beim ersten Klick auf "Link erzeugen". */
link: da ? `${gastgeber}/workspace/kalender/${da.schluessel}.ics` : null,
erstellt: da?.erstellt || null,
zuletzt: da?.zuletzt || null,
});
} catch (fehler) {
console.error("[ics] Lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Anlegen oder erneuern. Erneuern macht den alten Link im selben
* Moment ungueltig -- das ist der Zweck. */
icsRouter.post("/workspace/api/ics", angemeldet, gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const vorhanden = !!db().prepare(
"SELECT 1 FROM ics_schluessel WHERE person_id = ?").get(req.person.id);
const s = schluesselFuer(req.person.id, { neu: true });
const gastgeber = `${req.protocol}://${req.get("host")}`;
protokolliere(vorhanden ? "ics_erneuert" : "ics_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
});
res.json({ link: `${gastgeber}/workspace/kalender/${s}.ics`, erneuert: vorhanden });
} catch (fehler) {
console.error("[ics] Erzeugen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Abschalten. Danach gibt es keinen Link mehr -- nicht einen neuen. */
icsRouter.delete("/workspace/api/ics", angemeldet, gleicheHerkunft, (req, res) => {
try {
tabelle();
const e = db().prepare("DELETE FROM ics_schluessel WHERE person_id = ?")
.run(req.person.id);
protokolliere("ics_abgeschaltet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
});
res.json({ ok: true, entfernt: Number(e.changes) });
} catch (fehler) {
console.error("[ics] Abschalten:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});