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]>
369 lines
17 KiB
JavaScript
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 });
|
|
}
|