Gemessen am 07.09.2026, bevor irgendetwas geaendert wurde: Von drei Agentur-Eintraegen sah eine Creatorin genau EINEN -- den, der ihr zugeordnet war. Ein Event fuer alle traf weder `creator_id = ich` noch `erstellt_von = ich`; die Agentur-Seite war fuer jeden Creator leer. Nicht kaputt, nicht fehlerhaft: leer, so wie eine Seite aussieht, auf der noch nichts steht. Die Pruefung dazu war gruen. Sie legte ihre Testeintraege mit `creator_id: idLuna` an und pruefte damit einen Fall, den es im Alltag nicht gibt. - Bereichseinstellung `fuerAlle` + `ohneCreatorBezug`, daraus abgeleitet sichtbarEintrag(). BEWUSST neben sichtbar() statt darin: an derselben Funktion haengen Aufgaben, Dateien, Termine und Calls -- wer dort "1=1" einschleust, gibt nebenbei fremde Akten frei. Eine Gegenprobe mit einer zweiten Creatorin haelt das fest. - Die Zuordnung bietet nur noch "Agentur" an. Der Server verwirft eine Zuordnung ausserdem selbst -- inklusive des frei getippten Namens, und zwar NACH externPruefen: davor haette der Name den Riegel wieder aufgemacht. - "Event & Kampagne" heisst jetzt "Agentur-Events" und hat ein eigenes Formular: Von/Bis, Titel, Beschreibung, Aufgaben (Punkte und Preise), Regeln. Die Karte zeigt den Zustand als WORT (laeuft bis / startet / vorbei seit), nicht nur als Farbe. - Drei neue Spalten -- und sie stehen auch im Tabellenneubau vom 06.09. Der laeuft NACH dem Spaltennachtrag und haette sie samt Inhalt weggeworfen, ohne Fehler und mit stimmender Zeilenzahl. - Creator sehen weiterhin alles und tragen weiterhin nichts ein (403). Die Unterzeile sagt jetzt "alles, was hier steht" statt "alles, was zu dir gehoert" -- eine vollstaendige Liste soll sich nicht wie ein Ausschnitt lesen. pruef-agentur: 61 Pruefungen (vorher 40), alle gruen. Zwei eigene Fehler nebenbei gefunden und behoben: eine Beschriftung mit 11,2 px (Grenze 11,5) und ein Testdatum aus UTC statt Ortszeit. Zusaetzlich gruen: css-klassen, struktur, formulare, barrierefrei-workspace, handy. Co-Authored-By: Claude Opus 5 <[email protected]>
292 lines
13 KiB
JavaScript
292 lines
13 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, pipelineIds,
|
|
sichtbareCreatorIds,
|
|
} 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";
|
|
|
|
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 (istDogFather(person) || person.rolle === "scout" || person.rolle === "manager") {
|
|
const ids = istDogFather(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",
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
frage: q,
|
|
zu_kurz: false,
|
|
gruppen,
|
|
gesamt: gruppen.reduce((s, g) => s + g.treffer.length, 0),
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[workspace] Suche:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|