diff --git a/server/bild-arten.mjs b/server/bild-arten.mjs new file mode 100644 index 00000000..b342a077 --- /dev/null +++ b/server/bild-arten.mjs @@ -0,0 +1,189 @@ +/* Bild der Trennung Normal / Premium in der Scout-Pipeline (06.10.2026) + + Filipe: "ich will dass es da in der mitte eine trennung gibt. links + sollen normale creator sein und rechts premium creator." + + Die Zahlen sind gruen (pruef-scouting-felder, 63 Pruefungen) -- das + sagt, dass die Kaesten an der richtigen Stelle liegen, aber nicht, + wie es AUSSIEHT. Dafuer ist dieses Werkzeug da. + + Aufrufen mit: node server/bild-arten.mjs +*/ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-arten-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +const PORT = await portMussFreiSein(4336, "das Bildwerkzeug Arten"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1200)); +const BASIS = `http://127.0.0.1:${PORT}`; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const j = new Date().toISOString(); +const heute = j.slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, j); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const dogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const scout = anlegen("BananaStift", "scout", "CODE-SCOU-0001"); + +/* Ein realistischer Stand: beide Arten, mehrere Stufen, eine leere + Spalte -- genau die Faelle, die im Alltag vorkommen. */ +const lead = (name, handle, status, art, prio, land, follower, netz, followup) => + d.prepare(`INSERT INTO leads + (name, plattform, handle, status, prioritaet, scout_id, erstellt, erstellt_von, + creator_art, land, follower, netzwerk, naechster_followup, live_zeiten) + VALUES (?,'TikTok',?,?,?,?,?,?,?,?,?,?,?,?)`) + .run(name, handle, status, prio, scout, j, dogi, art, land, follower, netz, + followup, "Mo-Fr ab 21 Uhr"); + +lead("Tamy", "@coolemama1991", "neu", "normal", "hoch", "DE", 12400, "nein", heute); +lead("Mira", "@mira.live", "neu", "normal", "mittel", "AT", 3200, "unbekannt", null); +lead("Nala", "@nala", "neu", "premium", "hoch", "CH", 184000, "nein", null); +lead("Jo", "@jo_streamt", "angesprochen", "normal", "mittel", "DE", 8700, "nein", null); +lead("Elif", "@elif", "angesprochen", "premium", "hoch", "DE", 92000, "nein", heute); +lead("Sam", "@samsam", "gespraech", "normal", "niedrig", "LU", 1500, "ja", null); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const fehlerAufSeite = []; + +const anmelden = async (seite, rolle, code) => { + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); +}; + +/* ---- 1. Am Rechner ---------------------------------------------------- */ +const ctx = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 }); +const seite = await ctx.newPage(); +seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); }); +seite.on("pageerror", (f) => fehlerAufSeite.push(String(f))); +await anmelden(seite, "scout", "CODE-SCOU-0001"); +await seite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1800); + +console.log("\n=== 1. Am Rechner (1400 px) ==="); +const spalten = await seite.$$eval(".art-spalte", (ks) => ks.map((k) => ({ + stufe: k.closest(".stufe")?.dataset.stufe, + art: k.dataset.art, + titel: k.querySelector(".art-spalte__titel")?.textContent.trim(), + zahl: k.querySelector(".art-spalte__zahl")?.textContent.trim(), + x: Math.round(k.getBoundingClientRect().x), + breite: Math.round(k.getBoundingClientRect().width), + linie: getComputedStyle(k).borderLeftWidth, + namen: [...k.querySelectorAll(".kk__name")].map((n) => n.textContent.trim()).join(", ") + || k.querySelector(".art-spalte__leer")?.textContent.trim(), +}))); +for (const s of spalten) { + console.log(` ${String(s.stufe).padEnd(14)} ${String(s.titel).padEnd(8)} ${String(s.zahl).padStart(2)} ` + + `x=${String(s.x).padStart(4)} b=${String(s.breite).padStart(4)} Linie=${s.linie.padStart(5)} ${s.namen}`); +} +const stufe1 = await seite.$('.stufe[data-stufe="neu"]'); +await stufe1.screenshot({ path: "server/bild-arten-rechner.png" }); +await seite.screenshot({ path: "server/bild-arten-rechner-ganz.png", fullPage: false }); + +/* ---- 2. Das Formular -------------------------------------------------- */ +await seite.click("#neu-auf"); +await seite.waitForTimeout(600); +const feld = await seite.$("#neu-block"); +await feld.screenshot({ path: "server/bild-arten-formular.png" }); +console.log("\n=== 2. Im Formular ==="); +console.log(" Auswahl:", (await seite.$$eval("#n-art option", + (os) => os.map((o) => o.textContent.trim()))).join(" | ")); +await seite.click("#neu-zu"); + +/* ---- 3. Auf dem Handy (mit Finger!) ----------------------------------- */ +const handyKontext = await browser.newContext({ + viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 3, +}); +await handyKontext.addCookies(await ctx.cookies()); +const handy = await handyKontext.newPage(); +await handy.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); +await handy.waitForTimeout(1800); +console.log("\n=== 3. Auf dem Handy (390 px) ==="); +const schmal = await handy.$$eval('.stufe[data-stufe="neu"] .art-spalte', (ks) => ks.map((k) => ({ + art: k.dataset.art, y: Math.round(k.getBoundingClientRect().y), + breite: Math.round(k.getBoundingClientRect().width), + ueberRand: k.getBoundingClientRect().right > window.innerWidth, +}))); +for (const s of schmal) { + console.log(` ${String(s.art).padEnd(8)} y=${String(s.y).padStart(5)} b=${s.breite}` + + (s.ueberRand ? " UEBER DEN RAND" : "")); +} +const stufeH = await handy.$('.stufe[data-stufe="neu"]'); +await stufeH.screenshot({ path: "server/bild-arten-handy.png" }); + +/* ---- 4. Die Personenseite nach einer Uebergabe ------------------------- */ +const ctxD = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 }); +const dogiSeite = await ctxD.newPage(); +await anmelden(dogiSeite, "admin", "CODE-DOGI-0001"); +await dogiSeite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); +await dogiSeite.waitForTimeout(1500); +/* Elif (Premium) durch die Pipeline bis zur Uebergabe und ins Onboarding. */ +const elifId = await dogiSeite.evaluate(async () => { + const a = await fetch("/workspace/api/leads", { credentials: "same-origin" }); + const d2 = await a.json(); + return d2.leads.find((l) => l.name === "Elif")?.id; +}); +await dogiSeite.evaluate(async (id) => { + await fetch("/workspace/api/leads/" + id, { + method: "PATCH", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "uebergeben" }), + }); + await fetch(`/workspace/api/leads/${id}/onboarding`, { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Elif" }), + }); +}, elifId); +await dogiSeite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); +await dogiSeite.waitForTimeout(1800); +console.log("\n=== 4. Auf der Personenseite ==="); +/* Die Rollengruppen sind zugeklappt (Vorgabe: nur DogFather offen). + Ohne diesen Klick steht die Creator-Kachel gar nicht im Dokument -- + beim ersten Lauf meldete dieser Abschnitt deshalb nichts, und das + sah aus wie ein Fehler an der Art. */ +for (const kopf of await dogiSeite.$$(".gruppe__kopf--klappbar")) { + const t = (await kopf.textContent()) || ""; + if (t.toLowerCase().includes("creator")) { await kopf.click(); await dogiSeite.waitForTimeout(800); } +} +const creator = await dogiSeite.$$eval('.person[data-rolle="creator"]', (ks) => ks.map((k) => ({ + name: k.querySelector(".person__name")?.textContent.trim(), + marken: [...k.querySelectorAll(".marke-rolle")].map((m) => m.textContent.trim()).join(" - "), + art: k.querySelector('select[id^="creator-art-"]')?.value, +}))); +for (const c of creator) console.log(` ${String(c.name).padEnd(14)} [${c.marken}] Auswahl: ${c.art}`); +const kachel = await dogiSeite.$('.person[data-rolle="creator"]'); +if (kachel) await kachel.screenshot({ path: "server/bild-arten-person.png" }); + +console.log("\nFehler auf der Seite:", fehlerAufSeite.length ? fehlerAufSeite.join(" | ") : "keine"); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(0); diff --git a/server/pruef-scouting-felder.mjs b/server/pruef-scouting-felder.mjs index d58494e7..f0333aca 100644 --- a/server/pruef-scouting-felder.mjs +++ b/server/pruef-scouting-felder.mjs @@ -222,7 +222,113 @@ console.log("\n=== Die Pipeline gibt es auf der crew-Adresse nicht ==="); } /* ======================================================================= - 5. Und die Oberflaeche zeigt es auch + 5. Normal oder Premium -- die beiden Arten, die TikTok kennt + ======================================================================= */ +console.log("\n=== Normal und Premium ==="); +let premiumId = null, uebergebenId = null; +{ + /* Die Liste kommt vom Server, damit die Oberflaeche keine eigene + fuehrt -- und die REIHENFOLGE ist die der Spalten: links, rechts. */ + const { daten } = await ruf("GET", "/workspace/api/leads", keksScout); + ok(Array.isArray(daten?.arten) && daten.arten.length === 2 + && daten.arten[0] === "normal" && daten.arten[1] === "premium", + `die Artenliste kommt vom Server, in der Reihenfolge der Spalten (${(daten?.arten || []).join(", ")})`); + ok(daten?.art_name?.premium === "Premium", + `und ihre Namen (${daten?.art_name?.premium})`); + + /* DER LEAD VON OBEN HAT NIE EINE ART BEKOMMEN. Er muss trotzdem in + einer der beiden Spalten liegen, sonst waere er unsichtbar. */ + const alt = (daten?.leads || []).find((x) => x.id === leadId); + ok(alt?.creator_art === "normal", + `ein Kontakt ohne Angabe gilt als "normal" (${alt?.creator_art})`); +} +{ + const { status, daten } = await ruf("POST", "/workspace/api/leads", keksScout, { + name: "Nala", plattform: "TikTok", handle: "@nala", + creator_art: "premium", status: "angesprochen", + }); + ok(status === 201, `ein Premium-Kontakt laesst sich anlegen (HTTP ${status})`); + premiumId = daten?.id; + const liste = await ruf("GET", "/workspace/api/leads", keksScout); + const l = (liste.daten?.leads || []).find((x) => x.id === premiumId); + ok(l?.creator_art === "premium", `und bleibt Premium (${l?.creator_art})`); +} +{ + /* GEGENPROBEN. Eine erfundene Art wird NICHT stillschweigend zu + "normal" gemacht -- wer sich vertippt, soll es erfahren, statt die + Karte in der falschen Spalte zu suchen. */ + const falsch = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout, + { creator_art: "vip" }); + ok(falsch.status === 400, `eine erfundene Art wird abgelehnt (HTTP ${falsch.status})`); + + /* Und die Gegenprobe zur Gegenprobe: Umstellen geht. Ohne sie hiesse + "wird abgelehnt" auch "wird immer abgelehnt". */ + const zurueck = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout, + { creator_art: "normal" }); + ok(zurueck.status === 200, `auf "normal" umstellen geht (HTTP ${zurueck.status})`); + const wieder = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout, + { creator_art: "premium" }); + ok(wieder.status === 200, `und wieder zurueck auf "premium" (HTTP ${wieder.status})`); + const liste = await ruf("GET", "/workspace/api/leads", keksScout); + const l = (liste.daten?.leads || []).find((x) => x.id === premiumId); + ok(l?.creator_art === "premium", `der Wert steht danach richtig da (${l?.creator_art})`); +} + +/* ======================================================================= + 6. Die Art ueberlebt die Uebergabe + ======================================================================= */ +console.log("\n=== Vom Lead zum Creator ==="); +{ + const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); + const neu = await ruf("POST", "/workspace/api/leads", keksScout, { + name: "Nova", plattform: "TikTok", handle: "@nova", + creator_art: "premium", status: "uebergeben", + }); + uebergebenId = neu.daten?.id; + ok(neu.status === 201, `ein uebergebener Premium-Lead entsteht (HTTP ${neu.status})`); + + const ob = await ruf("POST", `/workspace/api/leads/${uebergebenId}/onboarding`, keksDogi, + { name: "Nova" }); + ok(ob.status === 201, `das Onboarding legt den Creator an (HTTP ${ob.status})`); + const creatorId = ob.daten?.id; + + const liste = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi); + const person = (liste.daten?.personen || []).find((x) => x.id === creatorId); + /* DAS IST DER PUNKT DIESES ABSCHNITTS: Ohne diese Zeile endet die + Angabe genau dort, wo sie zum ersten Mal vertraglich zaehlt. */ + ok(person?.creator_art === "premium", + `und die Art geht mit auf den Creator (${person?.creator_art})`); + ok(Array.isArray(liste.daten?.arten) && liste.daten.arten.length === 2, + `die Personenliste bringt dieselbe Artenliste mit (${(liste.daten?.arten || []).join(", ")})`); + + /* AENDERBAR, nicht nur gesetzt. Ein Wert, den man einmal setzt und nie + wieder, war am 11.09.2026 der eigentliche Mangel der Pipeline. */ + const um = await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`, + keksDogi, { creator_art: "normal" }); + ok(um.status === 200, `die Art laesst sich am Creator aendern (HTTP ${um.status})`); + const danach = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi); + const p2 = (danach.daten?.personen || []).find((x) => x.id === creatorId); + ok(p2?.creator_art === "normal", `und steht danach auf normal (${p2?.creator_art})`); + + /* Gegenproben: Unsinn und falsche Rolle. */ + const unsinn = await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`, + keksDogi, { creator_art: "deluxe" }); + ok(unsinn.status === 400, `eine erfundene Art wird auch hier abgelehnt (HTTP ${unsinn.status})`); + + const scoutPerson = (danach.daten?.personen || []).find((x) => x.rolle === "scout"); + const falscheRolle = await ruf("PUT", + `/workspace/api/verwaltung/personen/${scoutPerson?.id}/creator-art`, keksDogi, + { creator_art: "premium" }); + ok(falscheRolle.status === 404, + `an einem Scout gibt es keine Creator-Art (HTTP ${falscheRolle.status})`); + + /* Zurueck auf Premium -- die Oberflaechenpruefung unten erwartet es so. */ + await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`, + keksDogi, { creator_art: "premium" }); +} + +/* ======================================================================= + 7. Und die Oberflaeche zeigt es auch ======================================================================= */ console.log("\n=== Die Seite selbst ==="); { @@ -261,6 +367,79 @@ console.log("\n=== Die Seite selbst ==="); .catch(() => null); ok(warn === "Schon im Netzwerk", `die Netzwerk-Warnung ist zu sehen (${warn})`); + /* ===== LINKS NORMAL, RECHTS PREMIUM ============================= + + Filipe: "ich will dass es da in der mitte eine trennung gibt." + + Gemessen wird nicht, ob die Klassen da sind, sondern wo die Kaesten + WIRKLICH liegen -- links/rechts, auf gleicher Hoehe, mit einer + Trennlinie dazwischen. Eine Pruefung auf Klassennamen waere auch + dann gruen, wenn das CSS gar nicht geladen haette. */ + const spaltenLesen = (wo, stufe) => wo.$$eval( + `.stufe[data-stufe="${stufe}"] .art-spalte`, (ks) => ks.map((k) => { + const r = k.getBoundingClientRect(); + const stil = getComputedStyle(k); + return { + art: k.dataset.art, + titel: k.querySelector(".art-spalte__titel")?.textContent.trim() || null, + zahl: k.querySelector(".art-spalte__zahl")?.textContent.trim() || null, + /* Wie weit steht die Zahl von ihrem Wort entfernt? Siehe den + Fund vom 06.10.2026 in scouting.css: Eine fremde Regel aus + `aufgaben.css` hat sie ans andere Ende der Spalte gezogen, + und keine Pruefung wurde rot. */ + abstand: (() => { + const t = k.querySelector(".art-spalte__titel"); + const z = k.querySelector(".art-spalte__zahl"); + if (!t || !z) return null; + return Math.round(z.getBoundingClientRect().left - t.getBoundingClientRect().right); + })(), + x: Math.round(r.x), y: Math.round(r.y), breite: Math.round(r.width), + randLinks: parseFloat(stil.borderLeftWidth) || 0, + namen: [...k.querySelectorAll(".kk__name")].map((n) => n.textContent.trim()), + leer: k.querySelector(".art-spalte__leer")?.textContent.trim() || null, + }; + })); + + const neuSpalten = await spaltenLesen(seite, "neu"); + ok(neuSpalten.length === 2, `die Stufe "Neu" hat zwei Spalten (${neuSpalten.length})`); + ok(neuSpalten[0]?.art === "normal" && neuSpalten[0]?.titel === "Normal", + `links steht "Normal" (${neuSpalten[0]?.titel})`); + ok(neuSpalten[1]?.art === "premium" && neuSpalten[1]?.titel === "Premium", + `rechts steht "Premium" (${neuSpalten[1]?.titel})`); + /* LINKS IST WIRKLICH LINKS -- in Pixeln, nicht in der Reihenfolge im + Quelltext. `order` oder `direction` koennten sie drehen, ohne dass + am HTML etwas auffaellt. */ + ok(neuSpalten.length === 2 && neuSpalten[1].x > neuSpalten[0].x + 100, + `und zwar rechts DANEBEN (x ${neuSpalten[0]?.x} -> ${neuSpalten[1]?.x})`); + ok(neuSpalten.length === 2 && Math.abs(neuSpalten[0].y - neuSpalten[1].y) <= 4, + `beide beginnen auf gleicher Hoehe (y ${neuSpalten[0]?.y} / ${neuSpalten[1]?.y})`); + ok((neuSpalten[1]?.randLinks || 0) >= 1, + `die Trennung in der Mitte ist eine echte Linie (${neuSpalten[1]?.randLinks} px)`); + /* DIE ZAHL GEHOERT NEBEN IHR WORT. Nicht Geschmack: Eine Zahl am + anderen Ende einer 580 px breiten Spalte gehoert optisch zu nichts + mehr. Gefunden wurde das nur im Bildschirmfoto -- diese Messung + holt es in die Zahlen. */ + ok(neuSpalten.every((k) => k.abstand !== null && k.abstand >= 0 && k.abstand <= 20), + `die Zahl steht neben ihrem Wort (${neuSpalten.map((k) => k.abstand + " px").join(" / ")})`); + + /* UND DIE KARTEN LIEGEN AUCH DORT, WO SIE HINGEHOEREN. */ + ok(neuSpalten[0]?.namen.includes("Tamy"), + `der normale Kontakt steht links (${(neuSpalten[0]?.namen || []).join(", ") || "nichts"})`); + ok(neuSpalten[1]?.leer === "noch niemand", + `die leere Premium-Spalte bleibt stehen und sagt es (${neuSpalten[1]?.leer})`); + + const angSpalten = await spaltenLesen(seite, "angesprochen"); + ok(angSpalten[1]?.namen.includes("Nala"), + `der Premium-Kontakt steht rechts (${(angSpalten[1]?.namen || []).join(", ") || "nichts"})`); + ok(angSpalten[0]?.leer === "noch niemand", + `und links steht dort "noch niemand" (${angSpalten[0]?.leer})`); + + /* Die Marke auf der Karte -- nur bei Premium, nirgends sonst. */ + const premiumMarken = await seite.$$eval('.kk .marke-punkt[data-art="premium"]', + (ks) => ks.map((k) => k.closest(".kk")?.querySelector(".kk__name")?.textContent.trim())); + ok(premiumMarken.length === 2 && premiumMarken.includes("Nala") && premiumMarken.includes("Nova"), + `genau die zwei Premium-Karten tragen die Marke (${premiumMarken.join(", ")})`); + /* AUFKLAPPEN UND ZAEHLEN: Wie viele Felder stehen wirklich zur Verfuegung? Das ist Filipes Frage ("was man da noch alles eintragen kann") als Zahl. Vorher waren es fuenf. */ @@ -268,7 +447,59 @@ console.log("\n=== Die Seite selbst ==="); await seite.waitForTimeout(700); const felder = await seite.$$eval(".kk__detail input, .kk__detail select, .kk__detail textarea", (ks) => ks.length); - ok(felder >= 12, `im Editor stehen ${felder} Felder (vorher 5)`); + ok(felder >= 13, `im Editor stehen ${felder} Felder (vorher 5, mit der Art 13)`); + + /* Die Art ist im Editor dabei -- sonst waere sie beim Anlegen zu + setzen und danach nie wieder zu aendern. */ + const artImEditor = await seite.$$eval(".kk__detail select", + (ks) => ks.map((k) => [...k.options].map((o) => o.value).join(","))); + ok(artImEditor.includes("normal,premium"), + `im Editor steht die Art zur Auswahl (${artImEditor.join(" | ")})`); + + /* Und beim ANLEGEN -- das war der zweite Teil von Filipes Wunsch. */ + await seite.click("#neu-auf"); + await seite.waitForTimeout(300); + const neuArt = await seite.$$eval("#n-art option", + (os) => os.map((o) => `${o.value}:${o.textContent.trim()}`)); + ok(neuArt.length === 2 && neuArt[0] === "normal:Normal" && neuArt[1] === "premium:Premium", + `im Formular "Neuer Kontakt" kann man zwischen beiden waehlen (${neuArt.join(", ")})`); + await seite.click("#neu-zu"); + await seite.waitForTimeout(250); + + /* ===== GEGENPROBE: AUF DEM HANDY LIEGEN SIE UNTEREINANDER ======== + + Ohne diese Messung wuesste niemand, ob die Trennung ueberhaupt + umschaltet -- eine Spaltenregel, die bei 390 px zwei Spalten zu je + 170 px macht, waere unbedienbar und waere oben trotzdem gruen + gewesen. Gleichzeitig beweist sie, dass die erste Messung etwas + messen KANN: Dieselben Kaesten, dieselbe Abfrage, anderes Ergebnis. + + MIT EIGENEM KONTEXT UND MIT FINGER (hasTouch), nicht mit + `setViewportSize` auf derselben Seite. `hasTouch` gehoert zum + KONTEXT und laesst sich danach nicht nachtragen -- ohne ihn meldet + der Browser `pointer: fine`, und keine einzige Regel aus + `@media (pointer: coarse)` greift. Eine Messung mit Mauszeiger auf + 390 px vermisst eine Seite, die es auf keinem Telefon gibt, und + meldet dabei "in Ordnung". Genau das hat `pruef-fingermass` hier + beim ersten Anlauf gefunden -- die Hauswache, die es dafuer gibt. */ + const handyKontext = await browser.newContext({ + viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, + }); + await handyKontext.addCookies(await ctx.cookies()); + const handy = await handyKontext.newPage(); + await handy.goto(seite.url(), { waitUntil: "networkidle" }); + await handy.waitForTimeout(900); + const schmal = await spaltenLesen(handy, "neu"); + ok(schmal.length === 2, `auf dem Handy sind es immer noch zwei Spalten (${schmal.length})`); + ok(schmal.length === 2 && schmal[1].y > schmal[0].y + 40, + `aber untereinander statt nebeneinander (y ${schmal[0]?.y} -> ${schmal[1]?.y})`); + ok(schmal.length === 2 && Math.abs(schmal[0].x - schmal[1].x) <= 2, + `und beide gleich weit links (x ${schmal[0]?.x} / ${schmal[1]?.x})`); + ok((schmal[1]?.randLinks || 0) === 0, + `die senkrechte Trennlinie faellt dabei weg (${schmal[1]?.randLinks} px)`); + ok(schmal.every((k) => k.breite >= 300), + `keine Spalte wird gequetscht (${schmal.map((k) => k.breite).join(" / ")} px)`); + await browser.close(); } diff --git a/server/workspace-personen.js b/server/workspace-personen.js index 7bdcc801..3a3f616c 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -22,6 +22,11 @@ import { db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, sitzungToken, personSperren, betreuungSetzen, scoutZuteilungSetzen, istLeitung, istDogFather, siehtModis, siehtAlles, ROLLEN_SORTIERUNG, ROLLEN_REIHE, istSpicy, verborgeneIds, TEAM_DOGI_ROLLEN, darfAnlegen, darfRollenWechseln, darfRolleAendern, rollenZumAendern, hausBedingung, istHand, fuehrtDieZugaenge, } from "./workspace.js"; import { sicherungJetzt } from "./workspace-sicherung.js"; +/* Normal und Premium stehen bei der Scout-Pipeline, weil sie dort + entstehen. Hier wird dieselbe Liste benutzt und keine zweite + angelegt -- sonst koennte eine Seite einen Wert anbieten, den die + andere ablehnt. */ +import { ARTEN, ART_NAME, artVon } from "./workspace-scouts.js"; export const personenRouter = express.Router(); @@ -343,6 +348,12 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { res.json({ personen: db().prepare(` SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login, + /* Normal oder Premium (06.10.2026). Sie kommt beim + Creator-Onboarding aus der Scout-Pipeline mit und wird + ab da hier gepflegt. Bei allen anderen Rollen ist sie + leer und bedeutet nichts -- die Oberflaeche fragt + deshalb vorher nach der Rolle, nicht nach dem Wert. */ + p.creator_art, (SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen, (SELECT COUNT(*) FROM aufgaben a WHERE (a.creator_id = p.id OR a.verantwortlich_id = p.id) @@ -383,7 +394,13 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { LEFT JOIN scout_zuteilung sz ON sz.scout_id = p.id WHERE 1=1${hausBedingung(req.person, "p.rolle")} ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.aktiv DESC, p.name`) - .all().filter((z) => !weg.has(z.id)), + .all().filter((z) => !weg.has(z.id)) + /* NULL heisst "normal" -- aufgeloest wird das hier und nicht in + der Oberflaeche, damit es nicht zwei Meinungen darueber gibt. + Nur bei Creatorn: Bei allen anderen Rollen bleibt das Feld + leer, und "leer" ist dort die richtige Antwort. */ + .map((z) => (z.rolle === "creator" + ? { ...z, creator_art: artVon(z.creator_art) } : z)), /* Wer ueberhaupt als zustaendig eingetragen werden kann. Bis zum 31.08.2026 waren das nur Scouts. In der Auswahl stand @@ -401,6 +418,14 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { WHERE rolle IN ('admin', 'manager', 'scout') AND aktiv = 1 ORDER BY ${ROLLEN_SORTIERUNG}, name`).all().filter((z) => !weg.has(z.id)), + /* Normal und Premium kommen MIT -- aus derselben Liste wie in der + Scout-Pipeline. Die Oberflaeche fuehrt keine eigene: Sonst + stuenden die beiden Arten an drei Stellen im Haus (Server, + Pipeline-Seite, Personenseite), und die dritte waere die, die + beim naechsten Mal vergessen wird. Genau so ist es am 10.09. + schon einmal mit der Rollenwahl passiert. */ + arten: ARTEN, art_name: ART_NAME, + /* ============================================================= ROLLEN, DIE NICHT IM BROWSER STEHEN DUERFEN (09.09.2026) @@ -601,6 +626,46 @@ personenRouter.put("/workspace/api/verwaltung/betreuung/:id", gleicheHerkunft, ( } }); +/* ---------- Normal oder Premium (06.10.2026) ----------------------------- + + Die Art kommt beim Creator-Onboarding aus der Scout-Pipeline mit. Ab + hier haengt sie an der PERSON und laeuft dem Lead nicht mehr + hinterher: Ein Lead kann geloescht werden, ein Vertrag kann wechseln, + und die Pipeline ist ein Arbeitsstand, kein Register. + + DESHALB MUSS SIE HIER AENDERBAR SEIN. Ein Wert, den man einmal setzt + und danach nie wieder -- genau das war Filipes Befund vom 11.09.2026 + ueber die Pipeline ("man kan da nichts machen"), und ihn hier noch + einmal zu bauen waere derselbe Fehler mit einer anderen Spalte. */ + +personenRouter.put("/workspace/api/verwaltung/personen/:id/creator-art", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const creator = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id); + /* NUR an Creatorn. Ein Manager mit der Art "Premium" waere eine + Angabe, die nirgends etwas bedeutet -- und sie stuende dann in + der Liste, als haette jemand sie gemeint. */ + if (!creator || creator.rolle !== "creator") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const w = String(req.body?.creator_art ?? "").trim(); + if (!ARTEN.includes(w)) return res.status(400).json({ fehler: "Unbekannte Creator-Art." }); + + db().prepare("UPDATE personen SET creator_art = ? WHERE id = ?").run(w, id); + protokolliere("creator_art_gesetzt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${creator.name} -> ${ART_NAME[w] || w}`.slice(0, 120), + }); + res.json({ ok: true, creator_art: w }); + } catch (fehler) { + console.error("[workspace] Creator-Art setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Welcher Scout gehoert zu welchem Manager -------------------- "er soll nur die Scouts sehen, die ihm zugeteilt sind." (01.09.2026) diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index 2154f6f3..4a2a5c8f 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -55,6 +55,27 @@ export const NETZWERK = ["nein", "ja", "unbekannt"]; export const NETZWERK_NAME = { nein: "frei", ja: "schon im Netzwerk", unbekannt: "noch nicht gefragt", }; + +/* NORMAL ODER PREMIUM -- die beiden Arten, die TikTok kennt (06.10.2026). + + Filipe: "es gibt bei tiktok nämlich diese zwei optionen von streamer." + + GENAU ZWEI, und absichtlich kein "weiss nicht". Die Begruendung steht + bei der Spalte in workspace.js; kurz: Hier wird nicht eine fremde + Tatsache festgehalten, sondern eine eigene Absicht -- und deren + Normalfall ist "normal". + + DIE REIHENFOLGE IST DIE DER SPALTEN auf der Seite: links normal, + rechts premium. Die Oberflaeche liest sie von hier, sie fuehrt keine + eigene Liste -- sonst stuende die Trennung an zwei Stellen und wuerde + an einer davon geaendert. */ +export const ARTEN = ["normal", "premium"]; +export const ART_NAME = { normal: "Normal", premium: "Premium" }; + +/* NULL ist "normal". Diese Umrechnung steht an EINER Stelle, weil sonst + jede Abfrage, jede Spaltenzuteilung und jede Zaehlung ihre eigene + Meinung dazu haette, wo ein Lead ohne Eintrag hingehoert. */ +export const artVon = (wert) => (wert === "premium" ? "premium" : "normal"); const NAME_MAX = 120; const TEXT_MAX = 3000; @@ -120,6 +141,7 @@ const SPALTEN = ` l.aktivitaet, l.potenzial, l.notizen, l.letzte_nachricht, l.naechster_followup, l.scout_id, l.erstellt, l.geaendert, l.uebergeben_am, l.creator_id, l.follower, l.land, l.woher, l.kontaktweg, l.live_zeiten, l.netzwerk, l.absage_grund, + l.creator_art, ps.name AS scout_name, pc.name AS creator_name`; const VERBUND = ` @@ -143,7 +165,11 @@ scoutRouter.get("/workspace/api/leads", (req, res) => { andere nicht -- zuletzt bei der Rollenwahl am 10.09. */ laender: LAENDER, land_name: LAND_NAME, netzwerk: NETZWERK, netzwerk_name: NETZWERK_NAME, + arten: ARTEN, art_name: ART_NAME, heute, + /* `creator_art` wird hier schon aufgeloest (NULL -> "normal"), + damit die Oberflaeche nicht ihre eigene Regel dafuer braucht -- + sie waere die zweite und wuerde irgendwann die andere sagen. */ leads: db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} @@ -153,7 +179,8 @@ scoutRouter.get("/workspace/api/leads", (req, res) => { WHEN 'uebergeben' THEN 4 ELSE 5 END, CASE l.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, CASE WHEN l.naechster_followup IS NULL THEN 1 ELSE 0 END, - l.naechster_followup, l.id DESC`).all(...werte), + l.naechster_followup, l.id DESC`).all(...werte) + .map((l) => ({ ...l, creator_art: artVon(l.creator_art) })), }); } catch (fehler) { console.error("[workspace] Leads lesen:", fehler?.message); @@ -216,6 +243,19 @@ function pruefe(körper, { neu }) { else if (!NETZWERK.includes(w)) fehler.push("Unbekannte Angabe zum Netzwerk."); else aus.netzwerk = w; } + /* Normal oder Premium. Ein leerer Wert ist hier NICHT dasselbe wie bei + Land und Netzwerk: Dort heisst leer "nicht angegeben" und wird als + NULL gespeichert, hier gibt es keinen dritten Zustand -- leer faellt + auf "normal" zurueck, so wie es die Spalte ohnehin liest. Ein + erfundener Wert wird trotzdem abgelehnt und nicht stillschweigend + zu "normal" gemacht: Wer "premiumm" schickt, hat sich vertippt und + soll es erfahren, statt die Karte auf der falschen Seite zu finden. */ + if (körper.creator_art !== undefined) { + const w = String(körper.creator_art ?? "").trim(); + if (!w) aus.creator_art = "normal"; + else if (!ARTEN.includes(w)) fehler.push("Unbekannte Creator-Art."); + else aus.creator_art = w; + } if (körper.follower !== undefined) { const roh = String(körper.follower ?? "").trim(); if (!roh) aus.follower = null; @@ -270,8 +310,9 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { INSERT INTO leads (name, plattform, handle, status, prioritaet, aktivitaet, potenzial, notizen, letzte_nachricht, naechster_followup, scout_id, erstellt, erstellt_von, - follower, land, woher, kontaktweg, live_zeiten, netzwerk, absage_grund) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + follower, land, woher, kontaktweg, live_zeiten, netzwerk, absage_grund, + creator_art) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( aus.name, aus.plattform ?? null, aus.handle ?? null, aus.status ?? "neu", aus.prioritaet ?? "mittel", aus.aktivitaet ?? null, aus.potenzial ?? null, aus.notizen ?? null, @@ -279,7 +320,7 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { scoutId, jetzt(), req.person.id, aus.follower ?? null, aus.land ?? null, aus.woher ?? null, aus.kontaktweg ?? null, aus.live_zeiten ?? null, aus.netzwerk ?? null, - aus.absage_grund ?? null); + aus.absage_grund ?? null, aus.creator_art ?? "normal"); protokolliere("lead_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), @@ -382,6 +423,17 @@ scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, r db().prepare("UPDATE leads SET creator_id = ?, geaendert = ? WHERE id = ?") .run(neu.id, jetzt(), id); + /* DIE ART GEHT MIT (06.10.2026). + + Sonst endet die Angabe genau in dem Moment, in dem sie zum ersten + Mal vertraglich zaehlt: Der Scout haelt fest, dass jemand als + Premium geholt werden soll, das Onboarding legt die Person an -- + und am Creator steht es nirgends mehr. Ab hier ist es eine Angabe + an der Person und laeuft dem Lead nicht mehr hinterher; geaendert + wird sie danach in der Personenverwaltung. */ + db().prepare("UPDATE personen SET creator_art = ? WHERE id = ?") + .run(artVon(lead.creator_art), neu.id); + /* Die Angaben des Scouts wandern ins Creator-Profil -- sonst müsste das Management sie abtippen, obwohl sie längst da sind. */ const handles = [lead.plattform, lead.handle].filter(Boolean).join(" · ") || null; diff --git a/server/workspace.js b/server/workspace.js index 42704dee..4e6436e9 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -3921,7 +3921,34 @@ function umstellungen(d) { ["leads", "netzwerk", "TEXT"], // schon bei einer Agentur? nein/ja/unbekannt ["leads", "absage_grund", "TEXT"], // warum es nicht gepasst hat + /* ---- NORMAL ODER PREMIUM (06.10.2026) -------------------------- + + Filipe: "es gibt bei tiktok nämlich diese zwei optionen von + streamer ... auch dass wenn man die einträgt soll man aussuchen + können zwischen normalen und premium creator." + + Es sind GENAU ZWEI Werte, kein dritter. Das ist bewusst anders + als bei `netzwerk`, wo "noch nicht gefragt" eine eigene, gueltige + Antwort ist: Dort wird eine TATSACHE ueber eine fremde Person + festgehalten, die man wirklich nicht kennen kann. Hier wird + entschieden, unter welcher Art WIR jemanden holen wollen -- und + der Normalfall ist "normal". Ein dritter Zustand waere eine + Spalte, in der die Haelfte der Karten liegt, und die Trennung, + um die es geht, waere wieder keine. + + NULL heisst deshalb "normal" und wird auch so gelesen (siehe + `artVon()` in workspace-scouts.js). Die eine Zeile, die es am + 06.10. schon gab, steht damit links, ohne dass eine Umstellung + ihr etwas unterstellt, das niemand eingetragen hat. */ + ["leads", "creator_art", "TEXT"], // normal | premium + ["personen", "bild", "TEXT"], // Dateiname des Profilbildes + /* Die Art ueberlebt die Uebergabe (06.10.2026). Sie steht an der + PERSON und nicht im `profile`-Steckbrief: Unter welcher Art + jemand geholt wurde, ist eine Angabe der Agentur -- so wie die + Rolle --, keine, die der Creator ueber sich selbst schreibt. + Bei allen anderen Rollen bleibt sie leer und bedeutet nichts. */ + ["personen", "creator_art", "TEXT"], // normal | premium, nur bei Creatorn ["personen", "ueber_mich", "TEXT"], // ein Satz zur Person ["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @ ["personen", "instagram", "TEXT"], diff --git a/workspace/anleitung.html b/workspace/anleitung.html index 35facd46..292be482 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + + @@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index a90b8487..42710598 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +