/* ===================================================================== workspace-leistung.js — die Zahlen über das Geschäft. Der große Befund aus dem Plan vom 31.08.2026: Der Workspace organisiert ARBEIT hervorragend -- Aufgaben, Termine, Protokolle, Bereiche, Pipeline. Über das GESCHÄFT wusste er nichts. Keine Diamanten, keine LIVE-Tage, keine Verweildauer. Damit hing die ganze Betreuung in der Luft: * Der Start-Check bewertet, aber misst nicht. * Der Report fragt "was hat funktioniert?" -- ohne Beleg. * Das 90-Tage-Ziel ist ein Satz, kein Fortschritt. * "Creator im Fokus" zeigt überfällige Aufgaben, aber nicht, WESSEN ZAHLEN einbrechen. --------------------------------------------------------------------- DIE DREI REGELN GUTER KENNZAHLEN (Recherche 31.08./06.09.2026) Jede Zahl, die dieses Modul ausliefert, bringt mit: 1. VERGLEICH gegen die Vorperiode oder gegen das Ziel 2. VERLAUF die letzten Wochen als Reihe, damit man Richtung sieht 3. EINORDNUNG "+23 % gegenüber der Vorwoche" statt nur "1.240" Eine Zahl ohne diese drei ist eine Eitelkeitszahl. Sie sieht nach Auskunft aus und ist keine -- man kann nichts entscheiden. --------------------------------------------------------------------- DIE LEITZAHL IST DIE VERWEILDAUER Neu aus der Recherche vom 06.09.2026: 2026 hängt der Algorithmus alles an der Completion Rate. Und sie gehört als BETRIEBSkennzahl behandelt, nicht als kreative -- sie soll die nächste Runde steuern, nicht die letzte erklären. Deshalb steht sie überall vorn. --------------------------------------------------------------------- WAS DIESES MODUL NICHT TUT Es holt nichts von TikTok. Es gibt keine offene Agentur-Schnittstelle; Fremd-APIs kosten Geld, verstoßen gegen die Nutzungsbedingungen und brechen ohne Vorwarnung. Eingelesen wird der OFFIZIELLE Export -- und von Hand getippt, was fehlt. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, sichtbareCreatorIds, ROLLEN_SORTIERUNG, } from "./workspace.js"; export const leistungRouter = express.Router(); const jetzt = () => new Date().toISOString(); const NOTIZ_MAX = 500; /* ---------- Schranke ---------------------------------------------------- */ 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(); } leistungRouter.use("/workspace/api/leistung", angemeldet); /* ---------- Wer darf was ------------------------------------------------ */ /** Wessen Zahlen darf ich SEHEN? Dieselbe Regel wie überall sonst -- * eine eigene Sichtbarkeit für Zahlen wäre eine zweite Wahrheit, und * zwei Wahrheiten laufen auseinander. */ function darfSehen(person, creatorId) { const ids = sichtbareCreatorIds(person); if (ids === null) return true; // DogFather return ids.includes(Number(creatorId)); } /** Wer darf Zahlen EINTRAGEN? Leitung und Scouts -- nicht der Creator * selbst. Nicht aus Misstrauen: Die Zahlen kommen aus dem offiziellen * Export, und wer sie selbst tippt, hat den Export nicht. Eine Zahl, * die man sich selbst gibt, ist keine Messung. */ function darfEintragen(person) { return istLeitung(person) || person.rolle === "scout"; } /* ---------- Rechnen ----------------------------------------------------- */ const TAG_MS = 86400000; /** Ortszeit-Datum als "JJJJ-MM-TT". Nie toISOString(): Das ist UTC und * liegt zwischen Mitternacht und zwei Uhr einen Tag daneben. */ function tagText(d) { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } const tagVor = (n) => tagText(new Date(Date.now() - n * TAG_MS)); /** Summe und Durchschnitt über einen Zeitraum. * * DURCHSCHNITTE ZÄHLEN NUR TAGE MIT WERT. Ein Tag ohne LIVE ist kein * Tag mit 0 Zuschauern -- er ist ein Tag ohne Messung. Wer ihn * mitmittelt, redet jede Zahl klein, sobald jemand Urlaub hat. */ function zeitraum(creatorId, von, bis) { const z = db().prepare(` SELECT COUNT(*) AS tage_erfasst, COALESCE(SUM(diamanten), 0) AS diamanten, COALESCE(SUM(dauer_min), 0) AS dauer_min, COALESCE(SUM(gueltiger_tag), 0) AS gueltige_tage, COALESCE(SUM(follower_neu), 0) AS follower_neu, COALESCE(SUM(schenker), 0) AS schenker, AVG(CASE WHEN zuschauer_avg IS NOT NULL THEN zuschauer_avg END) AS zuschauer_avg, MAX(zuschauer_max) AS zuschauer_max, AVG(CASE WHEN verweildauer_s IS NOT NULL THEN verweildauer_s END) AS verweildauer_s FROM leistung WHERE creator_id = ? AND tag >= ? AND tag <= ?`) .get(creatorId, von, bis); return { tage_erfasst: z.tage_erfasst, diamanten: z.diamanten, dauer_min: z.dauer_min, gueltige_tage: z.gueltige_tage, follower_neu: z.follower_neu, schenker: z.schenker, zuschauer_avg: z.zuschauer_avg === null ? null : Math.round(z.zuschauer_avg), zuschauer_max: z.zuschauer_max, verweildauer_s: z.verweildauer_s === null ? null : Math.round(z.verweildauer_s), }; } /** Die Veränderung gegenüber der Vorperiode, in Prozent. * * VON NULL AUS GIBT ES KEINE PROZENT. 0 → 50 sind nicht "unendlich * Prozent besser", sondern schlicht "vorher nichts". Wer hier eine * Zahl erzwingt, bekommt Unsinn wie "+∞ %" auf den Bildschirm. */ function wandel(jetztWert, vorherWert) { if (jetztWert === null || vorherWert === null) return null; if (!vorherWert) return jetztWert ? { neu: true } : null; return { prozent: Math.round(((jetztWert - vorherWert) / vorherWert) * 100) }; } /** Der Verlauf: eine Zahl je Woche, die letzten N Wochen. * Für die kleine Linie neben der Zahl -- ohne Richtung ist ein Wert * nur ein Punkt. */ function verlauf(creatorId, wochen = 8) { const reihen = []; for (let i = wochen - 1; i >= 0; i--) { const bis = tagVor(i * 7); const von = tagVor(i * 7 + 6); const z = zeitraum(creatorId, von, bis); reihen.push({ von, bis, diamanten: z.diamanten, gueltige_tage: z.gueltige_tage, verweildauer_s: z.verweildauer_s, }); } return reihen; } /* ===================================================================== Die Schnittstellen ===================================================================== */ /** Die Zahlen eines Creators: diese Woche, Vorwoche, Ziel, Verlauf. */ leistungRouter.get("/workspace/api/leistung/:creatorId", (req, res) => { try { const creatorId = Number(req.params.creatorId); if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); /* Was man nicht sehen darf, existiert nicht -- 404, nicht 403. */ if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const dieseWoche = zeitraum(creatorId, tagVor(6), tagVor(0)); const vorwoche = zeitraum(creatorId, tagVor(13), tagVor(7)); const ziele = db().prepare("SELECT * FROM leistung_ziele WHERE creator_id = ?").get(creatorId); /* JEDE ZAHL MIT VERGLEICH UND EINORDNUNG -- die drei Regeln aus der Recherche. Eine nackte Zahl fliegt raus. */ const mitVergleich = {}; for (const feld of ["diamanten", "gueltige_tage", "dauer_min", "zuschauer_avg", "verweildauer_s", "schenker", "follower_neu"]) { mitVergleich[feld] = { wert: dieseWoche[feld], vorher: vorwoche[feld], wandel: wandel(dieseWoche[feld], vorwoche[feld]), }; } /* Das Ziel als Fortschritt, nicht als Satz. */ const zielstand = ziele ? { diamanten: ziele.diamanten_woche ? { ziel: ziele.diamanten_woche, ist: dieseWoche.diamanten, anteil: Math.round((dieseWoche.diamanten / ziele.diamanten_woche) * 100) } : null, tage: ziele.tage_woche ? { ziel: ziele.tage_woche, ist: dieseWoche.gueltige_tage, anteil: Math.round((dieseWoche.gueltige_tage / ziele.tage_woche) * 100) } : null, verweildauer: ziele.verweildauer_s && dieseWoche.verweildauer_s ? { ziel: ziele.verweildauer_s, ist: dieseWoche.verweildauer_s, anteil: Math.round((dieseWoche.verweildauer_s / ziele.verweildauer_s) * 100) } : null, } : null; res.json({ creator_id: creatorId, woche: mitVergleich, roh: { woche: dieseWoche, vorwoche }, ziele: zielstand, verlauf: verlauf(creatorId), /* Wie vollständig ist die Woche überhaupt? Eine Zahl aus zwei von sieben Tagen ist etwas anderes als eine aus sieben. Ohne das hält man eine halbe Woche für einen Einbruch. */ erfassung: { tage: dieseWoche.tage_erfasst, von: 7 }, }); } catch (fehler) { console.error("[leistung] Lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Die Tageszeilen eines Creators -- für die Schnelleingabe. */ leistungRouter.get("/workspace/api/leistung/:creatorId/tage", (req, res) => { try { const creatorId = Number(req.params.creatorId); if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const tage = Math.min(Math.max(Number(req.query.tage) || 14, 1), 120); const reihen = db().prepare(` SELECT * FROM leistung WHERE creator_id = ? AND tag >= ? ORDER BY tag DESC`).all(creatorId, tagVor(tage - 1)); res.json({ tage: reihen }); } catch (fehler) { console.error("[leistung] Tage:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ZIELE STEHEN VOR DER TAGES-ROUTE -- und das ist kein Stilfrage. Express nimmt die ERSTE Route, die passt. `:tag` passt auf jeden Text, also auch auf "ziele": Ein PUT auf .../ziele landete in der Tages-Route, scheiterte dort am Datumsmuster und gab 400 zurueck. Die Ziele liessen sich nicht setzen, und die Meldung sagte "ungueltig" -- was auf die Zahlen im Koerper hindeutet, nicht auf den Weg. Gefunden von pruef-leistung ("600 von 1200 Diamanten = undefined %"). Ohne die Pruefung waere es erst aufgefallen, wenn jemand ein Ziel setzen wollte. */ /** Ziele setzen. */ leistungRouter.put("/workspace/api/leistung/:creatorId/ziele", gleicheHerkunft, express.json({ limit: "4kb" }), (req, res) => { try { const creatorId = Number(req.params.creatorId); if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const z = (n) => { const w = Number(req.body?.[n]); return Number.isFinite(w) && w > 0 ? Math.round(w) : null; }; db().prepare(` INSERT INTO leistung_ziele (creator_id, diamanten_woche, tage_woche, verweildauer_s, gesetzt, gesetzt_von) VALUES (?,?,?,?,?,?) ON CONFLICT(creator_id) DO UPDATE SET diamanten_woche = excluded.diamanten_woche, tage_woche = excluded.tage_woche, verweildauer_s = excluded.verweildauer_s, gesetzt = excluded.gesetzt, gesetzt_von = excluded.gesetzt_von`) .run(creatorId, z("diamanten_woche"), z("tage_woche"), z("verweildauer_s"), jetzt(), req.person.id); res.json({ ok: true }); } catch (fehler) { console.error("[leistung] Ziele:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Einen Tag eintragen oder ändern. * * INSERT OR REPLACE auf (creator_id, tag): Derselbe Tag zweimal * eingetragen überschreibt, statt zu verdoppeln. Verdoppelte Zahlen * sind schlimmer als fehlende -- sie sehen richtig aus. */ leistungRouter.put("/workspace/api/leistung/:creatorId/:tag", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { try { const creatorId = Number(req.params.creatorId); const tag = String(req.params.tag); if (!Number.isInteger(creatorId) || !/^\d{4}-\d{2}-\d{2}$/.test(tag)) { return res.status(400).json({ fehler: "ungueltig" }); } if (!darfEintragen(req.person)) { return res.status(403).json({ fehler: "Zahlen tragen DogFather, Manager und Scouts ein." }); } if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); /* KEINE ZAHLEN AUS DER ZUKUNFT. Ein Tippfehler im Jahr (2027 statt 2026) legt sonst eine Zeile an, die nie wieder auffällt -- sie taucht in keiner Auswertung auf, weil alle nur zurückschauen. */ if (tag > tagVor(0)) { return res.status(400).json({ fehler: "Für einen Tag in der Zukunft gibt es noch keine Zahlen." }); } const z = (name) => { const w = req.body?.[name]; if (w === null || w === undefined || w === "") return null; const n = Number(w); /* Negative Werte gibt es hier nicht -- weder negative Diamanten noch negative Minuten. Ein Minus ist immer ein Tippfehler. */ return Number.isFinite(n) && n >= 0 ? Math.round(n) : null; }; const notiz = String(req.body?.notiz ?? "").trim().slice(0, NOTIZ_MAX) || null; db().prepare(` INSERT OR REPLACE INTO leistung (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_avg, zuschauer_max, verweildauer_s, schenker, follower_neu, notiz, erfasst, erfasst_von, quelle) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( creatorId, tag, z("diamanten"), z("dauer_min"), req.body?.gueltiger_tag ? 1 : 0, z("zuschauer_avg"), z("zuschauer_max"), z("verweildauer_s"), z("schenker"), z("follower_neu"), notiz, jetzt(), req.person.id, "hand"); protokolliere("leistung_erfasst", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Creator #${creatorId} ${tag}`, }); res.json({ ok: true }); } catch (fehler) { console.error("[leistung] Eintragen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Einen Tag löschen -- für den Fall, dass eine Zeile falsch angelegt * wurde. Kein "weg_am" wie beim Chat: Eine Messung, die es nicht gab, * soll auch nicht als leere Zeile herumstehen und Durchschnitte * verwässern. */ leistungRouter.delete("/workspace/api/leistung/:creatorId/:tag", gleicheHerkunft, (req, res) => { try { const creatorId = Number(req.params.creatorId); const tag = String(req.params.tag); if (!Number.isInteger(creatorId) || !/^\d{4}-\d{2}-\d{2}$/.test(tag)) { return res.status(400).json({ fehler: "ungueltig" }); } if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const e = db().prepare("DELETE FROM leistung WHERE creator_id = ? AND tag = ?") .run(creatorId, tag); protokolliere("leistung_geloescht", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Creator #${creatorId} ${tag}`, }); res.json({ ok: true, entfernt: Number(e.changes) }); } catch (fehler) { console.error("[leistung] Löschen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Überblick über ALLE sichtbaren Creator -- für das Dashboard. * Eine Abfrage statt einer je Creator. */ leistungRouter.get("/workspace/api/leistung", (req, res) => { try { const ids = sichtbareCreatorIds(req.person); const wo = ids === null ? "rolle = 'creator' AND aktiv = 1" : ids.length ? `id IN (${ids.map(() => "?").join(",")}) AND aktiv = 1` : "0=1"; const creator = db().prepare( `SELECT id, name FROM personen WHERE ${wo} ORDER BY name`) .all(...(ids === null ? [] : ids)); res.json({ creator: creator.map((c) => { const w = zeitraum(c.id, tagVor(6), tagVor(0)); const v = zeitraum(c.id, tagVor(13), tagVor(7)); return { id: c.id, name: c.name, diamanten: w.diamanten, gueltige_tage: w.gueltige_tage, verweildauer_s: w.verweildauer_s, wandel_diamanten: wandel(w.diamanten, v.diamanten), tage_erfasst: w.tage_erfasst, }; }), }); } catch (fehler) { console.error("[leistung] Überblick:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ===================================================================== CSV-Import aus dem offiziellen Export ===================================================================== ZWEIMAL EINLESEN DARF KEINEN SCHADEN ANRICHTEN. Das ist der häufigste Fehler bei Importen und der Grund für den Primärschlüssel (creator_id, tag): Dieselbe Datei ein zweites Mal überschreibt, statt zu verdoppeln. VORSCHAU VOR DEM ÜBERNEHMEN: Erst zeigen, was passieren wird, dann machen. Ein Import, der sofort schreibt, ist bei einer falsch zugeordneten Spalte nicht mehr zurückzuholen. */ /** Zerlegt CSV -- auch mit Anführungszeichen und Semikolon. * * Kein fremdes Paket: Die Formate, die hier vorkommen, sind * überschaubar, und eine Abhängigkeit für dreißig Zeilen wäre in * einem Haus mit drei Abhängigkeiten die vierte. */ export function csvZerlegen(text) { const zeilen = []; let feld = ""; let zeile = []; let inAnfuehrung = false; const roh = String(text).replace(/^/, ""); // BOM weg /* Trennzeichen erkennen: Deutsche Exporte nutzen oft Semikolon, englische Komma. Geraten wird an der ERSTEN Zeile -- dort, wo die Überschriften stehen. */ const kopf = roh.split(/\r?\n/)[0] || ""; const trenner = (kopf.match(/;/g) || []).length > (kopf.match(/,/g) || []).length ? ";" : ","; for (let i = 0; i < roh.length; i++) { const z = roh[i]; if (inAnfuehrung) { if (z === '"' && roh[i + 1] === '"') { feld += '"'; i++; } else if (z === '"') inAnfuehrung = false; else feld += z; } else if (z === '"') inAnfuehrung = true; else if (z === trenner) { zeile.push(feld); feld = ""; } else if (z === "\n") { zeile.push(feld); zeilen.push(zeile); zeile = []; feld = ""; } else if (z !== "\r") feld += z; } if (feld !== "" || zeile.length) { zeile.push(feld); zeilen.push(zeile); } return zeilen.filter((z) => z.some((f) => String(f).trim() !== "")); } /** Macht aus einem Textwert eine Zahl. * * Deutsche Exporte schreiben "1.234,5", englische "1,234.5". Beide * müssen gehen -- und "1.234" darf nicht als 1,234 gelesen werden. * Regel: Das LETZTE Trennzeichen entscheidet, ob es ein Komma- * oder Punktformat ist. */ export function zahlLesen(roh) { const t = String(roh ?? "").trim(); if (!t) return null; const nurZiffern = t.replace(/[^\d.,-]/g, ""); if (!nurZiffern) return null; const letztesKomma = nurZiffern.lastIndexOf(","); const letzterPunkt = nurZiffern.lastIndexOf("."); let sauber; if (letztesKomma >= 0 && letzterPunkt >= 0) { /* BEIDE kommen vor -- dann ist das LETZTE das Dezimalzeichen und das andere der Tausendertrenner. "1.234,5" und "1,234.5" sind damit eindeutig. */ sauber = letztesKomma > letzterPunkt ? nurZiffern.replace(/\./g, "").replace(",", ".") : nurZiffern.replace(/,/g, ""); } else if (letztesKomma >= 0 || letzterPunkt >= 0) { /* NUR EINES kommt vor -- und das ist die Falle. "1.250" ist deutsch Tausend-zweihundertfünfzig, englisch aber eins-komma-zwofünfnull. "1,234" genauso, nur andersherum. Die Regel "das letzte Trennzeichen ist das Dezimalzeichen" liefert hier für BEIDE Fälle das Falsche: Sie las "1.250" als 1,25 und "1,234" als 1,234 -- Faktor tausend daneben, und die Zahl sieht trotzdem plausibel aus. Entscheidend ist stattdessen, WIE VIELE Ziffern dahinter stehen: Genau drei heißt Tausendertrenner (so gruppiert man überall), alles andere heißt Dezimalstelle. "1.250" -> 3 Ziffern -> 1250 "1,234" -> 3 Ziffern -> 1234 "210,5" -> 1 Ziffer -> 210.5 "12.75" -> 2 Ziffern -> 12.75 Bleibt EIN unauflösbarer Fall: "1.500" könnte auch eins Komma fünf sein. Bei Diamanten, Minuten und Zuschauern ist die Tausenderdeutung die richtige -- Nachkommastellen kommen dort nicht vor. Deshalb wird hier so entschieden und nicht anders. */ const trenner = letztesKomma >= 0 ? "," : "."; const stelle = letztesKomma >= 0 ? letztesKomma : letzterPunkt; const dahinter = nurZiffern.length - stelle - 1; sauber = dahinter === 3 ? nurZiffern.split(trenner).join("") : nurZiffern.replace(trenner, "."); } else { sauber = nurZiffern; } const n = Number(sauber); return Number.isFinite(n) ? n : null; } /** Macht aus einem Textwert ein Datum "JJJJ-MM-TT". * Erkennt 2026-09-06, 06.09.2026 und 09/06/2026. */ export function datumLesen(roh) { const t = String(roh ?? "").trim(); let m; if ((m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t))) return `${m[1]}-${m[2]}-${m[3]}`; if ((m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(t))) { return `${m[3]}-${String(m[2]).padStart(2, "0")}-${String(m[1]).padStart(2, "0")}`; } if ((m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})/.exec(t))) { /* Amerikanisch: Monat zuerst. */ return `${m[3]}-${String(m[1]).padStart(2, "0")}-${String(m[2]).padStart(2, "0")}`; } return null; } /** Rät die Spaltenzuordnung aus den Überschriften. * Nur ein Vorschlag -- bestätigt wird er in der Vorschau. */ export function spaltenRaten(kopf) { const muster = { tag: /datum|date|tag|day/i, diamanten: /diamant|diamond/i, dauer_min: /dauer|duration|minut|zeit.*live|live.*zeit/i, zuschauer_avg: /(durchschnitt|avg|average|⌀|ø).*(zuschauer|viewer)|(zuschauer|viewer).*(durchschnitt|avg)/i, zuschauer_max: /(spitze|peak|max).*(zuschauer|viewer)|(zuschauer|viewer).*(spitze|peak|max)/i, verweildauer_s: /verweil|watch.*time|retention|zuschauzeit/i, schenker: /schenker|gifter|sender/i, follower_neu: /follower|abonnent/i, }; const zu = {}; kopf.forEach((name, i) => { for (const [feld, m] of Object.entries(muster)) { if (zu[feld] === undefined && m.test(String(name))) zu[feld] = i; } }); return zu; } /** Vorschau: Was WÜRDE passieren? Schreibt nichts. */ leistungRouter.post("/workspace/api/leistung/:creatorId/import/vorschau", gleicheHerkunft, express.json({ limit: "2mb" }), (req, res) => { try { const creatorId = Number(req.params.creatorId); if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const zeilen = csvZerlegen(String(req.body?.text || "")); if (zeilen.length < 2) { return res.status(400).json({ fehler: "Die Datei hat keine Datenzeilen." }); } const kopf = zeilen[0]; const zu = req.body?.spalten && typeof req.body.spalten === "object" ? req.body.spalten : spaltenRaten(kopf); if (zu.tag === undefined) { return res.status(400).json({ fehler: "Ich finde keine Datumsspalte. Bitte die Spalte zuordnen.", kopf, spalten: zu, }); } const neu = []; const ersetzt = []; const fehlerhaft = []; for (let i = 1; i < zeilen.length; i++) { const z = zeilen[i]; const tag = datumLesen(z[zu.tag]); if (!tag) { fehlerhaft.push({ zeile: i + 1, grund: `Datum unlesbar: "${z[zu.tag]}"` }); continue; } if (tag > tagVor(0)) { fehlerhaft.push({ zeile: i + 1, grund: `${tag} liegt in der Zukunft` }); continue; } const werte = { tag }; for (const [feld, spalte] of Object.entries(zu)) { if (feld === "tag" || spalte === undefined) continue; werte[feld] = zahlLesen(z[spalte]); } const vorhanden = db().prepare( "SELECT 1 FROM leistung WHERE creator_id = ? AND tag = ?").get(creatorId, tag); (vorhanden ? ersetzt : neu).push(werte); } res.json({ kopf, spalten: zu, /* Die ZAHLEN stehen vorn, die Beispiele dahinter: Wer übernimmt, will wissen "wie viele", nicht "welche". */ neu: neu.length, ersetzt: ersetzt.length, fehlerhaft: fehlerhaft.length, beispiele: neu.slice(0, 5), ersetzt_beispiele: ersetzt.slice(0, 5), probleme: fehlerhaft.slice(0, 10), }); } catch (fehler) { console.error("[leistung] Vorschau:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Übernehmen. Erst jetzt wird geschrieben. */ leistungRouter.post("/workspace/api/leistung/:creatorId/import", gleicheHerkunft, express.json({ limit: "2mb" }), (req, res) => { try { const creatorId = Number(req.params.creatorId); if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); const zeilen = csvZerlegen(String(req.body?.text || "")); if (zeilen.length < 2) return res.status(400).json({ fehler: "keine_daten" }); const zu = req.body?.spalten && typeof req.body.spalten === "object" ? req.body.spalten : spaltenRaten(zeilen[0]); if (zu.tag === undefined) return res.status(400).json({ fehler: "keine_datumsspalte" }); const d = db(); const schreiben = d.prepare(` INSERT OR REPLACE INTO leistung (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_avg, zuschauer_max, verweildauer_s, schenker, follower_neu, notiz, erfasst, erfasst_von, quelle) VALUES (?,?,?,?,?,?,?,?,?,?, /* Eine vorhandene Notiz NICHT wegwerfen: Sie ist von Hand geschrieben und steht in keinem Export. */ (SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?), ?,?, 'import')`); let geschrieben = 0, uebersprungen = 0; d.exec("BEGIN"); try { for (let i = 1; i < zeilen.length; i++) { const z = zeilen[i]; const tag = datumLesen(z[zu.tag]); if (!tag || tag > tagVor(0)) { uebersprungen++; continue; } const w = (feld) => (zu[feld] === undefined ? null : zahlLesen(z[zu[feld]])); const dauer = w("dauer_min"); schreiben.run( creatorId, tag, w("diamanten"), dauer, /* Ein gültiger Tag ist ein Tag mit LIVE-Zeit. Steht im Export nichts, wird aus der Dauer geschlossen -- eine eigene Spalte dafür gibt es meist nicht. */ dauer && dauer > 0 ? 1 : 0, w("zuschauer_avg"), w("zuschauer_max"), w("verweildauer_s"), w("schenker"), w("follower_neu"), creatorId, tag, jetzt(), req.person.id); geschrieben++; } d.exec("COMMIT"); } catch (f) { try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } throw f; } protokolliere("leistung_import", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Creator #${creatorId}: ${geschrieben} Tage`, }); res.json({ ok: true, geschrieben, uebersprungen }); } catch (fehler) { console.error("[leistung] Import:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });