Files
dogfather-universe/server-internal/routes/team.js
T
DogFatherGitandClaude Sonnet 5 971c5ea3f6 Bugfix: Team-Foto-Upload lieferte relativen statt vollen Pfad zurück
Gleicher Fehler wie in routes/events.js/uploadEventImage (dort gerade
direkt auf dem Server gefunden und gefixt, siehe git stash auf
dogiintern): die Verwaltung und die echte Website laufen auf
dogfather-universe.com (dogiweb), hochgeladene Fotos liegen aber unter
postfach.dogfather-universe.com (dogiintern). Ein relativer Pfad wurde
vom Browser also gegen die falsche Domain aufgelöst -> kaputtes
Bild-Symbol für jedes über die Team-Verwaltung hochgeladene Foto.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-21 04:37:04 +02:00

369 lines
17 KiB
JavaScript

/* =====================================================================
routes/team.js — Team-Mitglieder-Verwaltung (21.08.2026, Nutzer-Wunsch:
"überall wo Modis sind oder Scouts oder Manager, oder Creator, will ich
dass ich die easy über meine Verwaltungsseite hinzufüge und die
automatisch in der Website hinzugefügt werden ... auch mit Fotos und
TikTok Link").
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.
===================================================================== */
import { db } from "../db.js";
import { generateId, nowIso } from "../lib/crypto.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";
const KATEGORIEN = ["modi", "scout", "creator", "manager"];
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
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);
}
function parseJsonFeld(text) {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
/** 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. */
function slugAusName(name) {
return String(name || "")
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.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,
name: row.name,
slug: row.slug,
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,
};
}
function vollesFeld(row) {
return {
...oeffentlichesFeld(row),
status: row.status,
reihenfolge: row.reihenfolge,
erstelltAm: row.erstellt_am,
geaendertAm: row.geaendert_am,
};
}
/** Öffentlich, keine Session: nur AKTIVE Mitglieder. Ohne ?kategorie=
kommen alle vier Kategorien zusammen zurück (genutzt von profil.html,
das vorab nicht weiß, zu welcher Kategorie ein slug gehört). */
export async function getTeamPublic(req, res) {
const kategorie = String(req.query?.kategorie || "").trim();
let rows;
if (kategorie && KATEGORIEN.includes(kategorie)) {
rows = db
.prepare(`SELECT * FROM team_members WHERE kategorie = ? AND status = 'aktiv' ORDER BY reihenfolge ASC, erstellt_am ASC`)
.all(kategorie);
} else {
rows = db
.prepare(`SELECT * FROM team_members WHERE status = 'aktiv' ORDER BY reihenfolge ASC, erstellt_am ASC`)
.all();
}
return json(res, { ok: true, mitglieder: rows.map(oeffentlichesFeld) });
}
/** Für die Verwaltung: ALLE Mitglieder aller Kategorien/Status. */
export async function getTeamAdmin(req, res, session) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
const rows = db.prepare(`SELECT * FROM team_members ORDER BY kategorie ASC, reihenfolge ASC, erstellt_am ASC`).all();
return json(res, { ok: true, mitglieder: rows.map(vollesFeld) });
}
/** NUR Foto-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware).
Volle Adresse statt relativem Pfad (Bugfix 21.08.2026, siehe derselbe Fehler +
Korrektur in routes/events.js/uploadEventImage): die Verwaltung UND die echte
Startseite laufen auf dogfather-universe.com (dogiweb-Dienst), die hochgeladene
Datei liegt aber tatsächlich unter postfach.dogfather-universe.com (dogiintern-
Dienst, siehe /uploads-Static-Route in index.js) -- ein relativer Pfad hätte
der Browser also fälschlich gegen die falsche Domain aufgelöst und ein
kaputtes Bild-Symbol gezeigt, sowohl in der Verwaltungs-Vorschau als auch auf
team-modis.html/team-scouts.html/creator.html/manager.html. */
export async function uploadTeamPhoto(req, res, session) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
const url = `https://postfach.dogfather-universe.com/uploads/team/${req.file.filename}`;
logAction(session.actor, "team.photo_uploaded", req.file.filename, null);
return json(res, { ok: true, url });
}
export async function saveTeamMember(req, res, session) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
const body = req.body || {};
const id = String(body.id || "").trim() || null;
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);
if (!name) return json(res, { ok: false, error: "Name ist Pflicht." }, 400);
const rolleDe = String(body.rolle || "").trim().slice(0, 120);
if (!rolleDe) return json(res, { ok: false, error: "Rolle ist Pflicht." }, 400);
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;
if (tiktokRoh) {
if (/^https?:\/\//i.test(tiktokRoh)) {
tiktokUrl = tiktokRoh;
} else {
// Bequemlichkeit: reiner Benutzername (mit oder ohne @) wird
// automatisch zu einer vollen TikTok-URL ergänzt.
tiktokUrl = `https://www.tiktok.com/@${tiktokRoh.replace(/^@/, "")}`;
}
}
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;
const rang = body.rang !== undefined && body.rang !== null && body.rang !== ""
? 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, 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=@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(werte);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "team.updated", id, { name, kategorie });
} 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 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, 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) {
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
const id = String(req.body?.id || "");
const info = db.prepare(`DELETE FROM team_members WHERE id = ?`).run(id);
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
logAction(session.actor, "team.deleted", id, null);
return json(res, { ok: true });
}