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:
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user