Team-Verwaltung Runde 2: bestehende Mitglieder importieren + Kategorien/Seitentitel editierbar

Nutzer-Wunsch 21.08.2026: "wenn ich eine änderung mache ist es auch für
jeden für die die schon da sind und die neuen" + "ich will ich titel
und namen von kategorien und seiten ändern können in der verwaltung".

Backend (braucht Filipes manuellen Deploy):
- Migration 0008: team_members bekommt intro/bioHtml/extraCta-Spalten
  -- volle Feld-Parität mit den von Hand gepflegten Profilen (Diene/
  Patrick/Bananenstift/Marina nutzen diese Felder).
- routes/team.js: neue importLegacyMember()-Funktion -- übernimmt ein
  bestehendes Profil 1:1 in die Datenbank, OHNE erneut zu übersetzen
  (die vorhandenen, von Hand geschriebenen Übersetzungen bleiben
  erhalten). Idempotent: mehrfacher Import erzeugt keine Duplikate.
- routes/site-texts.js (neu): admin-editierbare Kategorie-Namen und
  Seitentitel, generischer key->{de,...}-Override über app_settings
  (wie "Event des Jahres"), fester Schlüssel-Katalog aus
  Sicherheitsgründen. Leeres Feld setzt auf den Standardtext zurück.
- 27 Tests in isolierter Testumgebung geprüft (kein better-sqlite3
  lokal kompilierbar).

Frontend:
- window.dogiTeamZusammenfuehren() (main.js): admin-Mitglieder
  ÜBERSCHREIBEN jetzt gleichnamige statische Einträge (per slug) statt
  sie zu duplizieren -- eine Bearbeitung wirkt dadurch für alle.
- window.dogiSiteTexteLaden() + applyTranslations() erweitert um
  data-site-text-key -- Kategorie-Seitentitel (team-modis.html/
  team-scouts.html/creator.html/manager.html) sind jetzt live editierbar.
- Verwaltung: "📥 Bestehende Mitglieder importieren"-Knopf (holt VanVan/
  Diene/Funny/Miss/Marina/Ghost/Patrick/Bananenstift aus den data-*.js-
  Dateien, VanVan bewusst ausgenommen -- eigene Sonderkarte + Seite),
  erweitertes Formular (Intro/ausführliche Vorstellung/zweiter Button,
  eingeklappt unter "Erweitert"), neue Sektion "Kategorien &
  Seitentitel" (4 Karten, sofort wirksam auf Website UND Verwaltung).

Beim Testen einen echten UX-Bug gefunden und gefixt: die "Gespeichert"-
Meldung nach dem Speichern eines Mitglieds wurde von der direkt
anschließenden Formular-Zurücksetzung sofort wieder überschrieben und
war nie sichtbar.

Cache-Busting-Version auf 20260821k erhöht.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-21 03:47:51 +02:00
co-authored by Claude Sonnet 5
parent 7060a4a6ae
commit 81f55d67db
41 changed files with 894 additions and 306 deletions
+1
View File
@@ -31,6 +31,7 @@ const MIGRATION_FILES = [
"0005_supporter_polls.sql",
"0006_testimonials.sql",
"0007_team_members.sql",
"0008_team_members_rich.sql",
];
function alreadyApplied(name) {
+5 -1
View File
@@ -72,8 +72,9 @@ import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
} from "./routes/polls.js";
import {
getTeamPublic, getTeamAdmin, saveTeamMember, deleteTeamMember, uploadTeamPhoto,
getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto,
} from "./routes/team.js";
import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js";
initDb();
@@ -180,6 +181,7 @@ app.get("/special-event", wrap(getSpecialEventPublic));
app.post("/testimonials/submit", wrap(submitTestimonial));
app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
app.get("/team", wrap(getTeamPublic));
app.get("/site-texts", wrap(getSiteTextsPublic));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
@@ -229,8 +231,10 @@ app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTes
// Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026
app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin));
app.post("/admin/team/save", requireTeamSession, withSession(saveTeamMember));
app.post("/admin/team/import", requireTeamSession, withSession(importLegacyMember));
app.post("/admin/team/delete", requireTeamSession, withSession(deleteTeamMember));
app.post("/admin/team/upload-photo", requireTeamSession, teamBildUpload.single("image"), withSession(uploadTeamPhoto));
app.post("/admin/site-texts/save", requireTeamSession, withSession(saveSiteText));
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
+95
View File
@@ -0,0 +1,95 @@
/* =====================================================================
routes/site-texts.js — admin-editierbare Kategorie-Namen & Seitentitel
(21.08.2026, Nutzer-Wunsch: "ich will ich titel und namen von
kategorien und seiten ändern können in der verwaltung").
Bewusst als generischer key->{de,"de-CH",en,fr,pt}-Override gebaut
(wiederverwendet die vorhandene app_settings-Tabelle, key-Präfix
"site_text_"), NICHT als eigene Tabelle -- dieselbe Technik wie
"Event des Jahres". Ein fester, serverseitiger Katalog erlaubter
Keys (ERLAUBTE_KEYS) verhindert, dass beliebige/kaputte Keys
gespeichert werden können. Aktuell nur die vier Team-Kategorien +
ihre Seitentitel belegt -- das Muster ist aber bewusst so gebaut,
dass in einer künftigen Runde weitere Seiten/Überschriften einfach
per neuem Key ergänzt werden können (siehe main.js:
applyTranslations() prüft data-site-text-key genau wie data-i18n).
Öffentlich lesbar (jede Seite braucht das ohne Login), Schreiben nur
mit TEAM_MANAGE (dieselbe Berechtigung wie die Team-Verwaltung,
bewusst keine eigene -- beides gehört zusammen). ===================================================================== */
import { db } from "../db.js";
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.js";
import { uebersetzeAlleSprachen } from "../lib/translate.js";
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
function forbidden(res) {
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
}
// key -> { label, maxLen } -- label ist nur für Log-/Fehlermeldungen.
const ERLAUBTE_KEYS = {
kategorie_modi: { label: "Kategorie-Name: Modi", maxLen: 40 },
kategorie_scout: { label: "Kategorie-Name: Scout", maxLen: 40 },
kategorie_creator: { label: "Kategorie-Name: Creator", maxLen: 40 },
kategorie_manager: { label: "Kategorie-Name: Manager", maxLen: 40 },
seitentitel_modi: { label: "Seitentitel: Modi-Team", maxLen: 100 },
seitentitel_scout: { label: "Seitentitel: Scout-Team", maxLen: 100 },
seitentitel_creator: { label: "Seitentitel: Creator", maxLen: 100 },
seitentitel_manager: { label: "Seitentitel: Manager", maxLen: 100 },
};
function settingsKey(key) {
return `site_text_${key}`;
}
/** Öffentlich, keine Session: liefert alle aktuell GESETZTEN Overrides
als { key: {de,"de-CH",en,fr,pt} }. Keys ohne eigenen Override
fehlen einfach im Ergebnis -- die aufrufende Seite behält dann ihren
eingebauten Standardtext (siehe main.js). */
export async function getSiteTextsPublic(req, res) {
const rows = db
.prepare(`SELECT key, value FROM app_settings WHERE key LIKE 'site_text_%'`)
.all();
const texte = {};
for (const row of rows) {
const kurzKey = row.key.replace(/^site_text_/, "");
try {
texte[kurzKey] = JSON.parse(row.value);
} catch {
/* kaputter Eintrag -> einfach überspringen, kein Fehler für alle anderen Keys */
}
}
return json(res, { ok: true, texte });
}
export async function saveSiteText(req, res, session) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
const body = req.body || {};
const key = String(body.key || "").trim();
const eintrag = ERLAUBTE_KEYS[key];
if (!eintrag) return json(res, { ok: false, error: "Unbekannter Text-Schlüssel." }, 400);
const textDe = String(body.text || "").trim().slice(0, eintrag.maxLen);
if (!textDe) {
// Leeres Feld = Override entfernen -> Seite fällt zurück auf ihren
// eingebauten Standardtext (Dogi kann so jederzeit "zurücksetzen").
db.prepare(`DELETE FROM app_settings WHERE key = ?`).run(settingsKey(key));
logAction(session.actor, "site_text.reset", key, null);
return json(res, { ok: true, text: null });
}
const text = await uebersetzeAlleSprachen(textDe);
db.prepare(
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
).run(settingsKey(key), JSON.stringify(text), new Date().toISOString());
logAction(session.actor, "site_text.saved", key, { textDe });
return json(res, { ok: true, text });
}
+181 -49
View File
@@ -5,12 +5,20 @@
automatisch in der Website hinzugefügt werden ... auch mit Fotos und
TikTok Link").
Ergänzt (überschreibt NICHT) die von Hand gepflegten Einträge in
assets/js/data-modis.js/data-scouts.js/data-creator.js/data-manager.js.
Die Website mischt beide Quellen zu einer gemeinsamen Liste (siehe
window.dogiTeamLaden() in main.js) -- ein Fehler hier kann die
bestehenden, fest einprogrammierten Profile (VanVan, Patrick, ...) nie
verschwinden lassen.
Update 21.08.2026 (Runde 2, Nutzer-Wunsch: "wenn ich eine änderung
mache ist es auch für jeden für die die schon da sind und die neuen"):
volle Feld-Parität mit den von Hand gepflegten Profilen (intro/bioHtml/
extraCta) PLUS ein Import-Weg für bestehende Profile (importLegacyMember)
-- danach lässt sich JEDES Mitglied (alt oder neu) hier bearbeiten,
und die Website zeigt (siehe window.dogiTeamLaden() in main.js) für
importierte slugs die DB-Version statt der statischen an.
WICHTIG: importLegacyMember übersetzt NICHT automatisch -- die
bestehenden Profile haben von Hand geschriebene, geprüfte Übersetzungen
in allen 5 Sprachen (siehe data-modis.js etc.), die dürfen beim Import
nicht durch Maschinenübersetzung ersetzt werden. saveTeamMember (Neu
anlegen/Bearbeiten in der Verwaltung) übersetzt weiterhin automatisch,
weil dort nur Deutsch eingetippt wird.
Öffentlicher Lesezugriff (getTeamPublic) ohne Session, nur AKTIVE
Mitglieder -- Schreibzugriff nur mit TEAM_MANAGE-Berechtigung.
@@ -23,6 +31,7 @@ import { logAction } from "../lib/audit.js";
import { uebersetzeAlleSprachen } from "../lib/translate.js";
const KATEGORIEN = ["modi", "scout", "creator", "manager"];
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
function can(session, key) {
if (!session) return false;
@@ -43,6 +52,25 @@ function parseJsonFeld(text) {
}
}
/** Prüft, ob ein vom Client geschicktes mehrsprachiges Feld wirklich ein
Objekt mit (mindestens) einem "de"-Text ist -- genutzt vom Import, der
KEINE eigene Übersetzung anstößt und deshalb ein vollständiges Objekt
erwartet, kein einzelnes Deutsch-Textfeld. */
function istGueltigesSprachFeld(x) {
return x && typeof x === "object" && typeof x.de === "string" && x.de.trim().length > 0;
}
/** Baut aus einem client-gesendeten mehrsprachigen Feld ein sauberes,
auf die bekannten 5 Sprachen begrenztes Objekt (verhindert, dass
beliebige zusätzliche Keys in die Datenbank gelangen). */
function saubereSprachen(x, maxLen) {
const out = {};
for (const sprache of SPRACHEN) {
out[sprache] = String(x?.[sprache] ?? x?.de ?? "").slice(0, maxLen);
}
return out;
}
/** Slug aus dem Namen ableiten (Kleinbuchstaben, Umlaute vereinfacht,
alles außer a-z0-9 wird zu "-"), falls die Verwaltung keinen eigenen
Slug mitschickt -- selbes Muster wie an anderen Stellen der Seite. */
@@ -55,7 +83,24 @@ function slugAusName(name) {
.slice(0, 60);
}
function slugFrei(slug, ausgenommeneId) {
const row = db.prepare(`SELECT id FROM team_members WHERE slug = ?`).get(slug);
return !row || row.id === ausgenommeneId;
}
/** Macht einen Slug bei Kollision automatisch eindeutig ("-2", "-3", ...)
statt hart abzulehnen -- Dogi/VanVan sollen nicht wegen eines
Namensdopplers (z.B. zwei Personen mit Spitzname "Foxy") hängen
bleiben. */
function eindeutigerSlug(wunschSlug, ausgenommeneId) {
if (slugFrei(wunschSlug, ausgenommeneId)) return wunschSlug;
let zaehler = 2;
while (!slugFrei(`${wunschSlug}-${zaehler}`, ausgenommeneId)) zaehler++;
return `${wunschSlug}-${zaehler}`;
}
function oeffentlichesFeld(row) {
const extraCtaLabel = parseJsonFeld(row.extra_cta_label_json);
return {
id: row.id,
kategorie: row.kategorie,
@@ -64,10 +109,13 @@ function oeffentlichesFeld(row) {
rolle: parseJsonFeld(row.rolle_json),
foto: row.foto_url || "",
tiktok: row.tiktok_url || "",
intro: parseJsonFeld(row.intro_json),
bio: parseJsonFeld(row.bio_json),
bioHtml: parseJsonFeld(row.bio_html_json),
zitat: parseJsonFeld(row.zitat_json),
rang: row.rang,
rangLabel: parseJsonFeld(row.rang_label_json),
extraCta: row.extra_cta_href && extraCtaLabel ? { href: row.extra_cta_href, label: extraCtaLabel } : null,
};
}
@@ -130,23 +178,9 @@ export async function saveTeamMember(req, res, session) {
const rolleDe = String(body.rolle || "").trim().slice(0, 120);
if (!rolleDe) return json(res, { ok: false, error: "Rolle ist Pflicht." }, 400);
let slug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60) || slugAusName(name);
if (!slug) return json(res, { ok: false, error: "Konnte keinen gültigen Slug ableiten -- bitte Namen prüfen." }, 400);
// Slug muss GLOBAL eindeutig sein (profil.html sucht kategorieübergreifend).
// Kollidiert er mit einem ANDEREN Eintrag (nicht dem gerade bearbeiteten),
// wird er automatisch mit "-2", "-3", ... eindeutig gemacht statt hart
// abzulehnen -- Dogi/VanVan sollen nicht wegen eines Namensdopplers
// (z.B. zwei Personen mit Spitzname "Foxy") hängen bleiben.
const slugBelegt = (kandidat) => {
const row = db.prepare(`SELECT id FROM team_members WHERE slug = ?`).get(kandidat);
return row && row.id !== id;
};
if (slugBelegt(slug)) {
let zaehler = 2;
while (slugBelegt(`${slug}-${zaehler}`)) zaehler++;
slug = `${slug}-${zaehler}`;
}
const wunschSlug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60) || slugAusName(name);
if (!wunschSlug) return json(res, { ok: false, error: "Konnte keinen gültigen Slug ableiten -- bitte Namen prüfen." }, 400);
const slug = eindeutigerSlug(wunschSlug, id);
const tiktokRoh = String(body.tiktok || "").trim().slice(0, 200);
let tiktokUrl = null;
@@ -160,7 +194,9 @@ export async function saveTeamMember(req, res, session) {
}
}
const introDe = String(body.intro || "").trim().slice(0, 400);
const bioDe = String(body.bio || "").trim().slice(0, 800);
const bioHtmlDe = String(body.bioHtml || "").trim().slice(0, 6000);
const zitatDe = String(body.zitat || "").trim().slice(0, 200);
const fotoUrl = String(body.foto || "").trim().slice(0, 300) || null;
@@ -168,54 +204,150 @@ export async function saveTeamMember(req, res, session) {
? Math.max(1, Math.min(99, Number(body.rang) || 0)) : null;
const rangLabelDe = String(body.rangLabel || "").trim().slice(0, 40);
const extraCtaHref = String(body.extraCtaHref || "").trim().slice(0, 300) || null;
const extraCtaLabelDe = String(body.extraCtaLabel || "").trim().slice(0, 60);
if (extraCtaHref && !/^https?:\/\//i.test(extraCtaHref) && !extraCtaHref.startsWith("/") && !extraCtaHref.endsWith(".html")) {
return json(res, { ok: false, error: "Zweiter Button: Link muss eine Seite (…html) oder http(s)-Adresse sein." }, 400);
}
const reihenfolge = Number.isFinite(Number(body.reihenfolge)) ? Number(body.reihenfolge) : 0;
const status = body.status === "pausiert" ? "pausiert" : "aktiv";
const [rolle, bio, zitat, rangLabel] = await Promise.all([
const [rolle, intro, bio, bioHtml, zitat, rangLabel, extraCtaLabel] = await Promise.all([
uebersetzeAlleSprachen(rolleDe),
introDe ? uebersetzeAlleSprachen(introDe) : Promise.resolve(null),
bioDe ? uebersetzeAlleSprachen(bioDe) : Promise.resolve(null),
bioHtmlDe ? uebersetzeAlleSprachen(bioHtmlDe) : Promise.resolve(null),
zitatDe ? uebersetzeAlleSprachen(zitatDe) : Promise.resolve(null),
rangLabelDe ? uebersetzeAlleSprachen(rangLabelDe) : Promise.resolve(null),
extraCtaLabelDe ? uebersetzeAlleSprachen(extraCtaLabelDe) : Promise.resolve(null),
]);
const jetzt = nowIso();
const werte = {
id: id || generateId(),
kategorie, name, slug,
rolle_json: JSON.stringify(rolle),
foto_url: fotoUrl,
tiktok_url: tiktokUrl,
intro_json: intro ? JSON.stringify(intro) : null,
bio_json: bio ? JSON.stringify(bio) : null,
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
zitat_json: zitat ? JSON.stringify(zitat) : null,
rang, rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
extra_cta_href: extraCtaHref && extraCtaLabel ? extraCtaHref : null,
extra_cta_label_json: extraCtaHref && extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
reihenfolge, status,
geaendert_am: jetzt, geaendert_von: session.actor,
};
if (id) {
const info = db
.prepare(
`UPDATE team_members SET
kategorie = ?, name = ?, slug = ?, rolle_json = ?, foto_url = ?, tiktok_url = ?,
bio_json = ?, zitat_json = ?, rang = ?, rang_label_json = ?, reihenfolge = ?,
status = ?, geaendert_am = ?, geaendert_von = ?
WHERE id = ?`
kategorie=@kategorie, name=@name, slug=@slug, rolle_json=@rolle_json,
foto_url=@foto_url, tiktok_url=@tiktok_url, intro_json=@intro_json,
bio_json=@bio_json, bio_html_json=@bio_html_json, zitat_json=@zitat_json,
rang=@rang, rang_label_json=@rang_label_json,
extra_cta_href=@extra_cta_href, extra_cta_label_json=@extra_cta_label_json,
reihenfolge=@reihenfolge, status=@status,
geaendert_am=@geaendert_am, geaendert_von=@geaendert_von
WHERE id=@id`
)
.run(
kategorie, name, slug, JSON.stringify(rolle), fotoUrl, tiktokUrl,
bio ? JSON.stringify(bio) : null, zitat ? JSON.stringify(zitat) : null,
rang, rangLabel ? JSON.stringify(rangLabel) : null, reihenfolge,
status, jetzt, session.actor, id
);
.run(werte);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "team.updated", id, { name, kategorie });
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(id);
return json(res, { ok: true, mitglied: vollesFeld(row) });
} else {
db.prepare(
`INSERT INTO team_members
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
extra_cta_href, extra_cta_label_json, reihenfolge, status,
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
VALUES
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
).run(werte);
logAction(session.actor, "team.created", werte.id, { name, kategorie });
}
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
return json(res, { ok: true, mitglied: vollesFeld(row) });
}
/** Importiert ein BESTEHENDES, von Hand gepflegtes Profil (aus
data-modis.js/data-scouts.js/data-creator.js/data-manager.js) 1:1 in
die Datenbank -- danach ist es über die Verwaltung wie jedes andere
Mitglied bearbeitbar, und Änderungen gelten für ALLE (siehe
window.dogiTeamLaden()-Override-Logik in main.js).
WICHTIG: übersetzt NICHTS automatisch (anders als saveTeamMember) --
erwartet für jedes Textfeld ein bereits vollständiges {de,"de-CH",en,
fr,pt}-Objekt, wie es in den data-*.js-Dateien von Hand gepflegt
wird. Ist ein Feld kein gültiges Sprachobjekt, wird es einfach
weggelassen statt einen Fehler zu werfen -- ein unvollständiges
Altprofil soll den Import nicht komplett blockieren.
Idempotent: ist der slug schon vorhanden, passiert nichts (kein
Duplikat, kein Fehler) -- der Button in der Verwaltung kann beliebig
oft geklickt werden, ohne Doppel-Einträge zu erzeugen. */
export async function importLegacyMember(req, res, session) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
const body = req.body || {};
const kategorie = String(body.kategorie || "").trim();
if (!KATEGORIEN.includes(kategorie)) return json(res, { ok: false, error: "Ungültige Kategorie." }, 400);
const name = String(body.name || "").trim().slice(0, 60);
const slug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60);
if (!name || !slug) return json(res, { ok: false, error: "Name und Slug sind Pflicht." }, 400);
if (!istGueltigesSprachFeld(body.rolle)) return json(res, { ok: false, error: "Rolle fehlt oder unvollständig." }, 400);
if (!slugFrei(slug, null)) {
return json(res, { ok: true, uebersprungen: true, grund: "Bereits importiert." });
}
const neueId = generateId();
const rolle = saubereSprachen(body.rolle, 120);
const intro = istGueltigesSprachFeld(body.intro) ? saubereSprachen(body.intro, 2000) : null;
const bio = istGueltigesSprachFeld(body.bio) ? saubereSprachen(body.bio, 800) : null;
const bioHtml = istGueltigesSprachFeld(body.bioHtml) ? saubereSprachen(body.bioHtml, 8000) : null;
const zitat = istGueltigesSprachFeld(body.zitat) ? saubereSprachen(body.zitat, 200) : null;
const rangLabel = istGueltigesSprachFeld(body.rangLabel) ? saubereSprachen(body.rangLabel, 40) : null;
const extraCtaLabel = body.extraCta?.href && istGueltigesSprachFeld(body.extraCta?.label) ? saubereSprachen(body.extraCta.label, 60) : null;
const jetzt = nowIso();
const werte = {
id: generateId(),
kategorie, name, slug,
rolle_json: JSON.stringify(rolle),
foto_url: String(body.foto || "").trim().slice(0, 300) || null,
tiktok_url: body.tiktok?.href || (typeof body.tiktok === "string" ? body.tiktok : null),
intro_json: intro ? JSON.stringify(intro) : null,
bio_json: bio ? JSON.stringify(bio) : null,
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
zitat_json: zitat ? JSON.stringify(zitat) : null,
rang: Number.isFinite(Number(body.rang)) ? Number(body.rang) : null,
rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
extra_cta_href: extraCtaLabel ? body.extraCta.href : null,
extra_cta_label_json: extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
reihenfolge: 0,
status: "aktiv",
geaendert_am: jetzt, geaendert_von: session.actor,
};
db.prepare(
`INSERT INTO team_members
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, bio_json, zitat_json,
rang, rang_label_json, reihenfolge, status, erstellt_am, geaendert_am, erstellt_von, geaendert_von)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
neueId, kategorie, name, slug, JSON.stringify(rolle), fotoUrl, tiktokUrl,
bio ? JSON.stringify(bio) : null, zitat ? JSON.stringify(zitat) : null,
rang, rangLabel ? JSON.stringify(rangLabel) : null, reihenfolge,
status, jetzt, jetzt, session.actor, session.actor
);
logAction(session.actor, "team.created", neueId, { name, kategorie });
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(neueId);
return json(res, { ok: true, mitglied: vollesFeld(row) });
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
extra_cta_href, extra_cta_label_json, reihenfolge, status,
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
VALUES
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
).run(werte);
logAction(session.actor, "team.imported", werte.id, { name, kategorie, slug });
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
return json(res, { ok: true, uebersprungen: false, mitglied: vollesFeld(row) });
}
export async function deleteTeamMember(req, res, session) {