Files
dogfather-universe/server/workspace-suche.js
T
DogFatherGitandClaude Opus 5 98cfdc3ebc Anleitung, Etappen 3 bis 7: alle vier Fassungen, Pflege, Suche, Rundgang, Abnahme
Der Bauplan ist damit vollstaendig umgesetzt.

ETAPPE 3 -- SCOUT, MANAGER UND SPICY MEDIA.
Woertlich aus Kapitel 07 bis 09, zusammen 84 Karten. Die Zahlen des
Bauplans stimmen auf die Karte genau: 18/21/22/23, verteilt 5/6/7,
5/7/9, 6/9/7, 5/5/13.

EINE ZWEIDEUTIGKEIT IM PDF, GEMESSEN STATT GERATEN. Beim Manager
nennt die Merke-Spalte "61, 58, 51 feste Punkte", und Layout- und
Raw-Auszug ordnen sie verschiedenen Kacheln zu. Nachgezaehlt im Code:
LIVE 69, Content-Ideen 61, Community 58, Technik 51. Damit ist die
zeilenweise Lesart bewiesen richtig -- die Layout-Lesart haette "61"
der LIVE-Analyse gegeben, die 69 hat.

Die Zuordnungspruefung laeuft jetzt fuer alle vier Rollen, und die
erwartete Kartenzahl wird AUS DER KACHELLISTE abgeleitet, nicht aus
der Abnahmeliste abgeschrieben. Dazu eine Gegenprobe, dass sich die
vier Fassungen wirklich unterscheiden -- sonst waeren alle Zeilen
gruen, solange die Kachellisten zufaellig passen.

ETAPPE 4 -- DIE PFLEGE, fuer DogFather UND Spicy.
Bearbeitet wird an Ort und Stelle: Stufe und die drei Zeilen je Karte,
Titel und Text je Listenzeile, Leitgedanke je Rolle. Fehlende Karten
werden benannt und lassen sich mit einem Griff anlegen -- das ist der
Fall "Fuer diese Kachel fehlt ein Eintrag" aus Kapitel 11. Dazu "Wer
ist wie weit": eine Arbeitslage, keine Ueberwachung.

KEINE ZWEITE ROLLENAUSWAHL. Die Vorschau gibt es schon -- sie heisst
"Meine Sicht" und steht in der Kopfleiste. Geprueft wird fuers Pflegen
`req.person` und nicht `req.sicht`: Wer durch fremde Augen sieht, soll
nicht aus Versehen in fremdem Namen aendern.

DIE MARKE "NEU" HAT EIN ENDE: 14 Tage ODER bis die Person die Seite
nach der Aenderung geoeffnet hat. Der Bauplan sagt dazu nichts, und
ohne Ende waere nach einem halben Jahr alles neu.

ETAPPE 5 -- DIE SUCHE.
Die Karten stehen in der Kopfleisten-Suche, aber nur die der EIGENEN
Rolle: Faende ein Creator die Manager-Karte, stuende dort "Zuteilen,
freigeben" neben einer Kachel, die er nicht hat. Der Treffer traegt
den Zusatz hinter dem Fragezeichen, sonst landen fuenf Karten auf
derselben Seite.

ETAPPE 6 -- BEGRUESSUNG UND RUNDGANG.
Fuenf Schritte ueber die Zentrale, im letzten leuchten nur die
Muss-Kacheln. EIGENE DATEI (`rundgang.js`) und nicht ein Stueck
start.js: Er laeuft auf der meistbenutzten Seite des Hauses, und wenn
hier etwas schiefgeht, darf davon nichts anderes betroffen sein.
Keine gerechnete Koordinate, kein Loch in einer Abdeckung -- die Seite
wird leiser, das Ziel bleibt hell. Faellt das Skript aus, ist beim
naechsten Laden alles normal.

DER ROLLENWECHSEL IST KEINE SONDERREGEL, sondern der Schluessel:
`(Person, Rolle)`. Die neue Rolle hat schlicht noch keine Zeile, also
startet die Einweisung von selbst noch einmal. Eine Spalte
"zuruecksetzen", an die jemand denken muesste, waere die Stelle, an
der es vergessen wird.

EIN FUND AUS DER MESSUNG: Gemerkt wurde zuerst erst am ENDE des
Rundgangs. Wer "Los geht's" drueckte und dann wegging, bekam die
Begruessung bei jedem Aufruf wieder -- fuer immer. Jetzt wird beim
ZEIGEN gemerkt; das ist die Tatsache, um die es geht.

ETAPPE 7 -- DIE ABNAHME.
Dabei fiel auf, dass der Rollen-Rundgang SPICY MEDIA gar nicht kannte:
fuenf Durchgaenge, aber zweimal Manager, zweimal Scout und Spicy nie.
Ihr gehoert die Gruppe "Rund um das Team" samt Team-Lage -- diese
Kachel ist in keinem Durchgang je geoeffnet worden. Jetzt laeuft sie
mit: 452 statt 406 Pruefungen.

MITGENOMMEN, WEIL ES ROT WAR: `pruef-eventkarte` und
`pruef-haus-luecke` bildeten ihr Tagesdatum aus UTC. Zwischen 00:00
und 02:00 deutscher Zeit liegt UTC im Vortag -- ein naechtlicher Lauf
haette an einem Datum gesucht, das er selbst nicht geschrieben hat.
Beide benutzen jetzt `helfer-zeit`. `pruef-struktur` ist damit gruen.

Gemessen (alle gruen, kein einziger Befund)
  pruef-anleitung        125 Pruefungen (vorher 70)
  pruef-rollen           452 Pruefungen (vorher 406) -- mit Spicy Media
  pruef-struktur         gruen (vorher rot)
  pruef-rechtetafel, pruef-css-klassen, pruef-kachel-universum,
  pruef-suchfeld, pruef-kachelraster, pruef-start-ansicht,
  pruef-sicht, pruef-eventkarte     alle gruen
  mess-anleitung         Rundgang fuenf Schritte, im letzten genau 5
                         Muss-Kacheln hervorgehoben; beim zweiten
                         Aufruf keine Begruessung mehr; kein
                         Querschieben bei 412 und 1440 px

Sicherung: ~/sicherungen/workspace-vor-anleitung-e37-20261006-1536.db

OFFEN, NICHT VON MIR UND NICHT AUS DIESEM BAUPLAN: pruef-haus-luecke
endet mit Rueckgabewert 3 ("konnte nicht nachsehen") -- eine der
beiden Adressen liefert das Anschlagbrett nicht. Nachgemessen: Das war
vor dieser Arbeit genauso.

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

367 lines
16 KiB
JavaScript

/* =====================================================================
workspace-suche.js — Suche über alle Bereiche (Konzept, Phase 4).
Zwei Dinge machen den Unterschied zwischen einer Suche und einer
brauchbaren Suche:
1. SIE DARF NICHTS FINDEN, WAS DIE SEITE VERBERGEN WUERDE. Eine Suche
ist die verlockendste Stelle fuer ein Datenleck: Man tippt einen
Namen und bekommt Treffer aus Bereichen, die man nie oeffnen
duerfte. Deshalb wird jede Quelle mit der Sichtbarkeitsregel ihres
eigenen Moduls abgefragt -- importiert, nicht abgeschrieben. Und
die management-internen Profilfelder (admin_notiz, plan_start,
naechster_review) werden gar nicht erst durchsucht, fuer niemanden
ausser dem Management.
2. SIE MUSS SAGEN, WO ETWAS STEHT. Ein Treffer ohne Textstelle und
ohne Weg dorthin zwingt zum Weitersuchen. Jeder Treffer traegt
deshalb einen Ausschnitt rund um die Fundstelle und ein Ziel.
Bewusst kein Volltextindex (FTS5). Die Datenmengen hier sind klein --
ein paar tausend Zeilen -- und LIKE braucht keinen zweiten Datenstand,
der irgendwann auseinanderlaeuft.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, siehtAlles, pipelineIds,
sichtbareCreatorIds, gehoertAufDieseAdresse,
} from "./workspace.js";
import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js";
import { sichtbar as sichtbarTermine } from "./workspace-kalender.js";
import { sichtbar as sichtbarDateien } from "./workspace-dateien.js";
/* sichtbarEintrag, NICHT sichtbar: Sonst faende ein Creator ueber die
Suche kein einziges Agentur-Event -- obwohl es auf der Agentur-Seite
vor ihm steht. Eine Suche, die weniger findet als die Seite zeigt,
liest sich wie "gibt es nicht". */
import { sichtbarEintrag as sichtbarEintraege, BEREICHE } from "./workspace-bereiche.js";
/* Welche Rollen eine Einweisung haben -- aus dem Fachmodul, nicht
danebengeschrieben. Eine zweite Liste hier waere die, die beim
naechsten Haus-Umbau stehen bleibt. */
import { FASSUNGEN as ANLEITUNG_ROLLEN } from "./anleitung-tabellen.js";
export const sucheRouter = express.Router();
const MIN = 2;
const JE_BEREICH = 6;
const AUSSCHNITT = 110;
/* Die Namen kommen aus workspace-bereiche.js, nicht aus einer zweiten
Liste hier. Eine abgeschriebene Liste vergisst den naechsten neuen
Bereich -- und in der Suche hiesse das: Er wird gefunden und traegt
dann keine Ueberschrift. */
const BEREICHSNAME = Object.fromEntries(
Object.entries(BEREICHE).map(([k, b]) => [k, b.name]));
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
sucheRouter.use("/workspace/api/suche", angemeldet);
/* LIKE-Sonderzeichen unschaedlich machen. Ohne das waere die Suche nach
"100%" eine Suche nach "100 gefolgt von irgendwas" und "a_b" faende
auch "axb" -- beides falsch und beides faellt erst spaet auf. */
function muster(text) {
return "%" + text.replace(/[\\%_]/g, (z) => "\\" + z) + "%";
}
/* Ausschnitt rund um die Fundstelle, damit man sieht, WARUM etwas
gefunden wurde -- nicht nur die ersten Zeichen des Feldes. */
function ausschnitt(text, suche) {
if (!text) return null;
const flach = String(text).replace(/\s+/g, " ").trim();
const wo = flach.toLowerCase().indexOf(suche.toLowerCase());
if (wo < 0) return flach.length > AUSSCHNITT ? flach.slice(0, AUSSCHNITT) + " …" : flach;
const von = Math.max(0, wo - 35);
const bis = Math.min(flach.length, wo + suche.length + 70);
return (von > 0 ? "… " : "") + flach.slice(von, bis) + (bis < flach.length ? " …" : "");
}
sucheRouter.get("/workspace/api/suche", (req, res) => {
try {
/* Die gewaehlte Sicht (nur DogFather, sonst er selbst) -- damit die
Suche dasselbe findet, was die Seiten gerade zeigen. */
const person = req.sicht || req.person;
const q = String(req.query.q ?? "").trim().slice(0, 100);
if (q.length < MIN) {
return res.json({ frage: q, zu_kurz: true, gruppen: [], gesamt: 0 });
}
const m = muster(q);
const gruppen = [];
/* Eine Quelle abfragen. Faellt eine aus, bleibt die Suche im Rest
benutzbar -- eine leere Trefferliste waere schlimmer als eine
unvollstaendige. */
const quelle = (titel, sql, werte, bau) => {
try {
const zeilen = db().prepare(sql).all(...werte);
if (!zeilen.length) return;
gruppen.push({ titel, treffer: zeilen.map(bau) });
} catch (fehler) {
console.error(`[workspace] Suche (${titel}):`, fehler?.message);
}
};
/* ---------- Aufgaben ---------- */
const a = sichtbarAufgaben(person);
if (a) {
quelle("Aufgaben", `
SELECT a.id, a.titel, a.beschreibung, a.status, a.frist, p.name AS creator_name
FROM aufgaben a LEFT JOIN personen p ON p.id = a.creator_id
WHERE ${a.wo} AND (a.titel LIKE ? ESCAPE '\\' OR a.beschreibung LIKE ? ESCAPE '\\')
ORDER BY a.status = 'erledigt', a.id DESC LIMIT ${JE_BEREICH}`,
[...a.werte, m, m],
(z) => ({
titel: z.titel,
text: ausschnitt(z.beschreibung, q),
zusatz: [z.creator_name, z.status].filter(Boolean).join(" · "),
ziel: "aufgaben.html",
}));
}
/* ---------- Termine, Calls und Protokolle ---------- */
const t = sichtbarTermine(person);
if (t) {
quelle("Termine & Calls", `
SELECT t.id, t.titel, t.beschreibung, t.art, t.beginn, t.ort, p.name AS creator_name
FROM termine t LEFT JOIN personen p ON p.id = t.creator_id
WHERE ${t.wo} AND (t.titel LIKE ? ESCAPE '\\' OR t.beschreibung LIKE ? ESCAPE '\\'
OR t.ort LIKE ? ESCAPE '\\')
ORDER BY t.beginn DESC LIMIT ${JE_BEREICH}`,
[...t.werte, m, m, m],
(z) => ({
titel: z.titel,
text: ausschnitt(z.beschreibung, q),
zusatz: [z.beginn?.replace("T", " · "), z.creator_name].filter(Boolean).join(" · "),
ziel: z.art === "termin" ? "kalender.html" : "calls.html",
}));
/* Protokolle haengen an Terminen und erben deren Sichtbarkeit.
Hier steckt oft das, wonach man wirklich sucht: was besprochen
und was entschieden wurde. */
quelle("Gesprächsprotokolle", `
SELECT pr.id, pr.punkte, pr.entscheidungen, t.titel, t.beginn
FROM protokolle pr JOIN termine t ON t.id = pr.termin_id
WHERE ${t.wo} AND (pr.punkte LIKE ? ESCAPE '\\' OR pr.entscheidungen LIKE ? ESCAPE '\\')
ORDER BY t.beginn DESC LIMIT ${JE_BEREICH}`,
[...t.werte, m, m],
(z) => {
const treffer = (z.entscheidungen || "").toLowerCase().includes(q.toLowerCase())
? { feld: "Entscheidung", wert: z.entscheidungen }
: { feld: "Besprochen", wert: z.punkte };
return {
titel: z.titel,
text: ausschnitt(treffer.wert, q),
zusatz: `${treffer.feld} · ${(z.beginn || "").slice(0, 10).split("-").reverse().join(".")}`,
ziel: "calls.html",
};
});
}
/* ---------- Dateien ---------- */
const d = sichtbarDateien(person);
if (d) {
quelle("Dateien", `
SELECT d.id, d.name_original, d.notiz, d.status, p.name AS creator_name
FROM dateien d LEFT JOIN personen p ON p.id = d.creator_id
WHERE ${d.wo} AND (d.name_original LIKE ? ESCAPE '\\' OR d.notiz LIKE ? ESCAPE '\\')
ORDER BY d.id DESC LIMIT ${JE_BEREICH}`,
[...d.werte, m, m],
(z) => ({
titel: z.name_original,
text: ausschnitt(z.notiz, q),
zusatz: [z.creator_name, z.status].filter(Boolean).join(" · "),
ziel: "dateien.html",
}));
}
/* ---------- Betreuungsbereiche ---------- */
const e = sichtbarEintraege(person);
if (e) {
quelle("Betreuungsbereiche", `
SELECT e.id, e.bereich, e.titel, e.text, e.datum, e.status, p.name AS creator_name
FROM eintraege e LEFT JOIN personen p ON p.id = e.creator_id
WHERE ${e.wo} AND (e.titel LIKE ? ESCAPE '\\' OR e.text LIKE ? ESCAPE '\\')
ORDER BY e.datum DESC LIMIT ${JE_BEREICH}`,
[...e.werte, m, m],
(z) => ({
titel: z.titel,
text: ausschnitt(z.text, q),
zusatz: [BEREICHSNAME[z.bereich] || z.bereich, z.creator_name].filter(Boolean).join(" · "),
ziel: `bereich.html?b=${z.bereich}`,
}));
}
/* ---------- Scout-Pipeline ---------- */
/* NUR DogFather sieht alle Leads (01.09.2026). Ein Scout seine
eigenen, ein Manager seine eigenen und die seiner zugeteilten
Scouts -- dieselbe Regel wie in der Pipeline selbst, aus
derselben Quelle (pipelineIds in workspace.js). Ein Hinweis, der
mehr verraet als die Seite dahinter, waere ein Leck. */
if (siehtAlles(person) || person.rolle === "scout" || person.rolle === "manager") {
const ids = siehtAlles(person) ? null : pipelineIds(person);
const nur = ids === null ? "1=1"
: ids.length ? `l.scout_id IN (${ids.map(() => "?").join(",")})` : "0=1";
const werte = ids === null ? [] : ids;
quelle("Scout-Pipeline", `
SELECT l.id, l.name, l.plattform, l.handle, l.status, l.notizen, l.potenzial, l.aktivitaet
FROM leads l
WHERE ${nur} AND (l.name LIKE ? ESCAPE '\\' OR l.handle LIKE ? ESCAPE '\\'
OR l.notizen LIKE ? ESCAPE '\\' OR l.potenzial LIKE ? ESCAPE '\\'
OR l.aktivitaet LIKE ? ESCAPE '\\')
ORDER BY l.id DESC LIMIT ${JE_BEREICH}`,
[...werte, m, m, m, m, m],
(z) => ({
titel: z.name,
text: ausschnitt(z.notizen || z.potenzial || z.aktivitaet, q),
zusatz: [z.plattform, z.handle, z.status].filter(Boolean).join(" · "),
ziel: "scouting.html",
}));
}
/* ---------- Creator-Profile ----------
Nur die offenen Felder. admin_notiz, plan_start und
naechster_review werden NICHT durchsucht -- auch nicht fuer das
Management, denn ein Treffer daraus taucht sonst spaeter in einer
Ansicht auf, die diese Felder gar nicht zeigen darf. Wer die Notiz
lesen will, oeffnet das Profil. */
if (istLeitung(person) || person.rolle === "scout") {
/* Zuteilung statt Rolle (03.09.2026): Eine Managerin fand hier
die Profile ALLER Creator -- Nische, Kanäle, Sendezeiten,
Technik. Die Suche ist die unauffälligste Stelle für so ein
Leck: Man sucht nach etwas ganz anderem und bekommt fremde
Namen als Beifang, ohne dass jemand es je bemerkt. */
let nur = "";
let werte = [];
const erlaubt = sichtbareCreatorIds(person);
if (erlaubt !== null) {
if (!erlaubt.length) nur = null;
else {
nur = ` AND p.id IN (${erlaubt.map(() => "?").join(",")})`;
werte = erlaubt;
}
}
if (nur !== null) {
quelle("Creator-Profile", `
SELECT p.id, p.name, f.nische, f.handles, f.live_zeiten, f.technik,
f.ziel_live, f.ziel_content, f.ziel_community, f.ziel_technik,
f.plan_prio1, f.plan_prio2, f.plan_prio3
FROM personen p LEFT JOIN profile f ON f.person_id = p.id
WHERE p.rolle = 'creator' AND p.aktiv = 1${nur}
AND (p.name LIKE ? ESCAPE '\\' OR f.nische LIKE ? ESCAPE '\\'
OR f.handles LIKE ? ESCAPE '\\' OR f.live_zeiten LIKE ? ESCAPE '\\'
OR f.technik LIKE ? ESCAPE '\\'
OR f.ziel_live LIKE ? ESCAPE '\\' OR f.ziel_content LIKE ? ESCAPE '\\'
OR f.ziel_community LIKE ? ESCAPE '\\' OR f.ziel_technik LIKE ? ESCAPE '\\'
OR f.plan_prio1 LIKE ? ESCAPE '\\' OR f.plan_prio2 LIKE ? ESCAPE '\\'
OR f.plan_prio3 LIKE ? ESCAPE '\\')
ORDER BY p.name LIMIT ${JE_BEREICH}`,
[...werte, ...Array(12).fill(m)],
(z) => {
/* Zeigen, WELCHES Feld getroffen hat -- sonst raetselt man,
warum ein Profil auftaucht. */
const felder = [
["Nische", z.nische], ["Handles", z.handles], ["LIVE-Zeiten", z.live_zeiten],
["Technik", z.technik], ["Ziel LIVE", z.ziel_live], ["Ziel Content", z.ziel_content],
["Ziel Community", z.ziel_community], ["Ziel Technik", z.ziel_technik],
["Priorität 1", z.plan_prio1], ["Priorität 2", z.plan_prio2],
["Priorität 3", z.plan_prio3],
];
const t2 = felder.find(([, w]) => w && w.toLowerCase().includes(q.toLowerCase()));
return {
titel: z.name,
text: t2 ? ausschnitt(t2[1], q) : null,
zusatz: t2 ? t2[0] : "Name",
ziel: "profil.html",
};
});
}
}
/* ---------- Die Anleitung (06.10.2026, Etappe 5) ----------------
NUR DIE EIGENE FASSUNG. Jede Rolle hat ihre eigenen Karten mit
eigenen Texten; faende ein Creator die Karte des Managers,
stuende dort "Zuteilen, freigeben" neben einer Kachel, die er
gar nicht hat. Deshalb `rolle = ?` in der Bedingung und nicht
nur die Adresspruefung weiter unten -- die trennt die Haeuser,
nicht die Rollen.
DER NAME DER KACHEL STEHT NICHT IN DER DATENBANK (siehe
anleitung-tabellen.js), also kann die Suche ihn auch nicht
durchsuchen. Gefunden wird ueber die drei Saetze, und als Titel
steht der Kachel-Schluessel -- aus `bereich:schutz` wird
"Schutz". Das ist ehrlicher, als einen Namen zu erfinden, der
dann nicht mehr zu der Kachel passt, die inzwischen anders
heisst. */
{
const rolle = String(person?.rolle || "");
if (ANLEITUNG_ROLLEN.includes(rolle)) {
quelle("Anleitung", `
SELECT kachel, stufe, wozu, tust, merke
FROM anl_karte
WHERE haus = 'agentur' AND rolle = ?
AND (wozu LIKE ? ESCAPE '\\' OR tust LIKE ? ESCAPE '\\'
OR merke LIKE ? ESCAPE '\\' OR kachel LIKE ? ESCAPE '\\')
ORDER BY reihe LIMIT ${JE_BEREICH}`,
[rolle, m, m, m, m],
(z) => {
const felder = [["Wozu", z.wozu], ["Was du hier tust", z.tust], ["Merke", z.merke]];
const treffer = felder.find(([, w]) => w && w.toLowerCase().includes(q.toLowerCase()));
/* Aus dem Schluessel wieder etwas Lesbares machen:
`bereich:schutz` -> `Schutz`, `manager-ziele` -> `Manager-ziele`. */
const name = String(z.kachel).split(":").pop().replace(/^./, (c) => c.toUpperCase());
return {
titel: name,
text: treffer ? ausschnitt(treffer[1], q) : null,
zusatz: ["Einweisung", treffer ? treffer[0] : null].filter(Boolean).join(" · "),
/* Mit dem Zusatz hinter dem Fragezeichen -- sonst landen
fuenf Karten auf derselben Seite. */
ziel: z.kachel.startsWith("bereich:")
? `bereich.html?b=${z.kachel.slice(8)}`
: `${z.kachel}.html`,
};
});
}
}
/* UND HIER GILT SIE AUCH (15.09.2026).
Ein Suchtreffer ist ein Verweis auf eine Seite. Zeigt er auf eine,
die es auf dieser Adresse nicht gibt, fuehrt er zur Startseite
zurueck -- und man sucht ihn beim naechsten Mal wieder.
Die Daten waren nie das Problem: Die filtert die Haustrennung seit
dem 10.09. Das Ziel war es. Gefunden habe ich das nicht durch
Nachdenken, sondern beim Nachsehen, WER SONST NOCH Seitenziele
ausliefert, nachdem Filipe den Hinweis gemeldet hatte.
LEERE GRUPPEN FALLEN MIT WEG. Eine Ueberschrift ohne Treffer
darunter sieht aus, als waere etwas kaputtgegangen. */
const hier = { ...person, haus: (req.person || person)?.haus };
const erlaubt = gruppen
.map((g) => ({
...g,
treffer: (g.treffer || []).filter((t) => !t?.ziel
|| gehoertAufDieseAdresse(hier, "/workspace/" + String(t.ziel).split("?")[0])),
}))
.filter((g) => g.treffer.length);
res.json({
frage: q,
zu_kurz: false,
gruppen: erlaubt,
/* Abgeleitet, nicht mitgezaehlt -- sonst nennt die Zahl Treffer,
die gar nicht dastehen. */
gesamt: erlaubt.reduce((s, g) => s + g.treffer.length, 0),
});
} catch (fehler) {
console.error("[workspace] Suche:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});