diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 8c42a97..5f75854 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug | | ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext | | exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext | +| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext | **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review). +Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation). --- diff --git a/backend/main.py b/backend/main.py index 3fe7898..93b1e36 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -203,6 +203,7 @@ app.include_router(clubs.router) app.include_router(club_memberships.router) app.include_router(club_join_requests.router) app.include_router(admin_users.router) +app.include_router(admin_user_content.router) app.include_router(platform_media_storage.router) app.include_router(media_assets.router) app.include_router(media_assets.admin_rights_router) diff --git a/backend/routers/admin_user_content.py b/backend/routers/admin_user_content.py new file mode 100644 index 0000000..4e7f51b --- /dev/null +++ b/backend/routers/admin_user_content.py @@ -0,0 +1,519 @@ +""" +Superadmin API: Übersicht und Moderation nutzerangelegter Inhalte (inkl. private). + +# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext. +Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field, model_validator + +from auth import require_auth +from club_tenancy import is_superadmin +from db import get_db, get_cursor, r2d +from media_lifecycle import superadmin_hard_delete_media_asset + +router = APIRouter(prefix="/api/admin/user-content", tags=["admin_user_content"]) + +_VALID_VISIBILITY = frozenset({"private", "club", "official"}) +_VALID_EXERCISE_STATUS = frozenset({"draft", "in_review", "approved", "archived"}) +_VALID_MATURITY_STATUS = frozenset({"draft", "active", "archived"}) +_VALID_MEDIA_RIGHTS = frozenset({"legacy_unreviewed", "declared", "blocked"}) +_VALID_MEDIA_LIFECYCLE = frozenset({"active", "trash_soft", "trash_hidden"}) +_MAX_ITEMS_LIMIT = 100 + +ContentType = Literal[ + "exercise", + "training_module", + "framework_program", + "progression_graph", + "plan_template", + "maturity_model", + "media_asset", +] + +_CONTENT_SPECS: Dict[str, Dict[str, Any]] = { + "exercise": { + "label": "Übung", + "table": "exercises", + "creator_col": "created_by", + "title_col": "title", + "status_col": "status", + "visibility_col": "visibility", + "club_col": "club_id", + "has_status": True, + "has_visibility": True, + "status_values": sorted(_VALID_EXERCISE_STATUS), + }, + "training_module": { + "label": "Trainingsmodul", + "table": "training_modules", + "creator_col": "created_by", + "title_col": "title", + "status_col": None, + "visibility_col": "visibility", + "club_col": "club_id", + "has_status": False, + "has_visibility": True, + "status_values": [], + }, + "framework_program": { + "label": "Rahmenprogramm", + "table": "training_framework_programs", + "creator_col": "created_by", + "title_col": "title", + "status_col": None, + "visibility_col": "visibility", + "club_col": "club_id", + "has_status": False, + "has_visibility": True, + "status_values": [], + }, + "progression_graph": { + "label": "Progressionspfad", + "table": "exercise_progression_graphs", + "creator_col": "created_by", + "title_col": "name", + "status_col": None, + "visibility_col": "visibility", + "club_col": "club_id", + "has_status": False, + "has_visibility": True, + "status_values": [], + }, + "plan_template": { + "label": "Trainingsvorlage", + "table": "training_plan_templates", + "creator_col": "created_by", + "title_col": "name", + "status_col": None, + "visibility_col": "visibility", + "club_col": "club_id", + "has_status": False, + "has_visibility": True, + "status_values": [], + }, + "maturity_model": { + "label": "Reifegradmodell", + "table": "maturity_models", + "creator_col": "created_by", + "title_col": "name", + "status_col": "status", + "visibility_col": None, + "club_col": "club_id", + "has_status": True, + "has_visibility": False, + "status_values": sorted(_VALID_MATURITY_STATUS), + }, + "media_asset": { + "label": "Medium", + "table": "media_assets", + "creator_col": "uploaded_by_profile_id", + "title_col": "original_filename", + "status_col": "rights_status", + "visibility_col": "visibility", + "club_col": "club_id", + "extra_col": "lifecycle_state", + "has_status": True, + "has_visibility": True, + "status_values": sorted(_VALID_MEDIA_RIGHTS), + }, +} + + +def _require_superadmin(session: dict) -> dict: + role = (session.get("role") or "").strip().lower() + if not is_superadmin(role): + raise HTTPException(status_code=403, detail="Nur Superadmins") + return session + + +def _spec(content_type: str) -> Dict[str, Any]: + key = (content_type or "").strip().lower() + spec = _CONTENT_SPECS.get(key) + if not spec: + raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp") + return spec + + +def _types_for_filters( + content_type: Optional[str], + status: Optional[str], +) -> List[str]: + if content_type and content_type != "all": + return [content_type] + types = list(_CONTENT_SPECS.keys()) + if status: + types = [t for t in types if _CONTENT_SPECS[t].get("has_status")] + return types + + +def _build_type_select(spec: Dict[str, Any], content_type: str) -> str: + title = spec["title_col"] + creator = spec["creator_col"] + status = spec.get("status_col") + visibility = spec.get("visibility_col") + club = spec.get("club_col") + extra = spec.get("extra_col") + status_sql = f"t.{status}" if status else "NULL" + vis_sql = f"t.{visibility}" if visibility else "NULL" + club_sql = f"t.{club}" if club else "NULL" + extra_sql = f"t.{extra}" if extra else "NULL" + return f""" + SELECT + '{content_type}' AS content_type, + t.id, + t.{title} AS title, + t.{creator} AS profile_id, + {status_sql} AS status, + {vis_sql} AS visibility, + {club_sql} AS club_id, + {extra_sql} AS extra_status, + t.created_at, + t.updated_at + FROM {spec['table']} t + """ + + +def _append_filters( + where: List[str], + params: List[Any], + *, + spec: Dict[str, Any], + profile_id: Optional[int], + visibility: Optional[str], + status: Optional[str], + search: Optional[str], +) -> None: + creator = spec["creator_col"] + if profile_id is not None: + where.append(f"t.{creator} = %s") + params.append(profile_id) + + if visibility and visibility != "all": + vis_col = spec.get("visibility_col") + if vis_col: + where.append(f"t.{vis_col} = %s") + params.append(visibility) + + if status: + st_col = spec.get("status_col") + if st_col: + where.append(f"t.{st_col} = %s") + params.append(status) + + if search: + title_col = spec["title_col"] + where.append(f"t.{title_col} ILIKE %s") + params.append(f"%{search}%") + + +def _exercise_delete_usage_message(cur, exercise_id: int) -> str: + cur.execute( + """ + SELECT + (SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items, + (SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items, + (SELECT COUNT(*)::int FROM exercise_progression_edges + WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges + """, + (exercise_id, exercise_id, exercise_id, exercise_id), + ) + row = r2d(cur.fetchone() or {}) + parts = [] + if int(row.get("block_items") or 0): + parts.append(f"{row['block_items']}× in Übungsblöcken") + if int(row.get("section_items") or 0): + parts.append(f"{row['section_items']}× in Trainingsplänen oder Rahmenabläufen") + if int(row.get("prog_edges") or 0): + parts.append(f"{row['prog_edges']}× in Progressionsgraphen (Kanten)") + if not parts: + return "" + return ( + "Die Übung wird noch verwendet und kann nicht gelöscht werden. " + "Bitte auf „archiviert“ setzen. Verwendung: " + ", ".join(parts) + "." + ) + + +class UserContentPatchBody(BaseModel): + status: Optional[str] = None + visibility: Optional[str] = None + lifecycle_state: Optional[str] = None + + @model_validator(mode="after") + def at_least_one_field(self): + if self.status is None and self.visibility is None and self.lifecycle_state is None: + raise ValueError("Mindestens eines der Felder status, visibility oder lifecycle_state angeben") + return self + + +@router.get("/meta") +def get_user_content_meta(session: dict = Depends(require_auth)): + """Metadaten zu unterstützten Inhaltstypen.""" + _require_superadmin(session) + types = [] + for key, spec in _CONTENT_SPECS.items(): + types.append( + { + "id": key, + "label": spec["label"], + "has_status": spec["has_status"], + "has_visibility": spec["has_visibility"], + "status_values": spec.get("status_values") or [], + } + ) + return {"content_types": types} + + +@router.get("/users-summary") +def list_users_content_summary(session: dict = Depends(require_auth)): + """Anzahl angelegter Inhalte je Nutzer (alle Sichtbarkeiten).""" + _require_superadmin(session) + + count_exprs: List[str] = [] + for key, spec in _CONTENT_SPECS.items(): + creator = spec["creator_col"] + count_exprs.append( + f"(SELECT COUNT(*)::int FROM {spec['table']} WHERE {creator} = p.id) AS {key}_count" + ) + counts_sql = ",\n ".join(count_exprs) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + SELECT + p.id, + p.name, + p.email, + p.role, + p.created_at, + {counts_sql}, + ( + {" + ".join(f"COALESCE((SELECT COUNT(*)::int FROM {spec['table']} WHERE {spec['creator_col']} = p.id), 0)" for spec in _CONTENT_SPECS.values())} + ) AS total_count + FROM profiles p + WHERE EXISTS ( + SELECT 1 FROM exercises e WHERE e.created_by = p.id + UNION ALL SELECT 1 FROM training_modules tm WHERE tm.created_by = p.id + UNION ALL SELECT 1 FROM training_framework_programs fp WHERE fp.created_by = p.id + UNION ALL SELECT 1 FROM exercise_progression_graphs pg WHERE pg.created_by = p.id + UNION ALL SELECT 1 FROM training_plan_templates pt WHERE pt.created_by = p.id + UNION ALL SELECT 1 FROM maturity_models mm WHERE mm.created_by = p.id + UNION ALL SELECT 1 FROM media_assets ma WHERE ma.uploaded_by_profile_id = p.id + ) + ORDER BY total_count DESC, COALESCE(lower(trim(p.email)), ''), p.id + """ + ) + rows = [] + for r in cur.fetchall(): + d = r2d(r) + counts = {k: int(d.pop(f"{k}_count") or 0) for k in _CONTENT_SPECS} + d["counts_by_type"] = counts + d["total_count"] = int(d.get("total_count") or 0) + rows.append(d) + return rows + + +@router.get("/items") +def list_user_content_items( + session: dict = Depends(require_auth), + profile_id: Optional[int] = Query(default=None, ge=1), + content_type: str = Query(default="all"), + visibility: Optional[str] = Query(default="all"), + status: Optional[str] = Query(default=None), + search: Optional[str] = Query(default=None, max_length=200), + limit: int = Query(default=50, ge=1, le=_MAX_ITEMS_LIMIT), + offset: int = Query(default=0, ge=0), +): + """Paginierte Inhaltsliste — Superadmin sieht auch private Inhalte.""" + _require_superadmin(session) + + ct_raw = (content_type or "all").strip().lower() + if ct_raw != "all" and ct_raw not in _CONTENT_SPECS: + raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp") + + vis_raw = (visibility or "all").strip().lower() + if vis_raw not in ("all", *_VALID_VISIBILITY): + raise HTTPException(status_code=400, detail="Ungültiger Sichtbarkeits-Filter") + + types = _types_for_filters(ct_raw if ct_raw != "all" else None, status) + if not types: + return {"items": [], "total": 0, "limit": limit, "offset": offset} + + unions: List[str] = [] + all_params: List[Any] = [] + for tkey in types: + spec = _CONTENT_SPECS[tkey] + where: List[str] = ["TRUE"] + params: List[Any] = [] + _append_filters( + where, + params, + spec=spec, + profile_id=profile_id, + visibility=vis_raw, + status=(status or "").strip().lower() or None, + search=(search or "").strip() or None, + ) + unions.append(_build_type_select(spec, tkey) + " WHERE " + " AND ".join(where)) + all_params.extend(params) + + union_sql = " UNION ALL ".join(unions) + count_sql = f"SELECT COUNT(*)::int AS c FROM ({union_sql}) sub" + list_sql = f""" + SELECT sub.*, + p.name AS profile_name, + p.email AS profile_email, + c.name AS club_name + FROM ({union_sql}) sub + LEFT JOIN profiles p ON p.id = sub.profile_id + LEFT JOIN clubs c ON c.id = sub.club_id + ORDER BY sub.updated_at DESC NULLS LAST, sub.id DESC + LIMIT %s OFFSET %s + """ + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(count_sql, tuple(all_params)) + count_row = cur.fetchone() + total = int(r2d(count_row).get("c") or 0) + + cur.execute(list_sql, tuple(all_params + [limit, offset])) + items = [] + for r in cur.fetchall(): + d = r2d(r) + d["type_label"] = _CONTENT_SPECS[d["content_type"]]["label"] + items.append(d) + + return {"items": items, "total": total, "limit": limit, "offset": offset} + + +@router.patch("/items/{content_type}/{item_id}") +def patch_user_content_item( + content_type: ContentType, + item_id: int, + body: UserContentPatchBody, + session: dict = Depends(require_auth), +): + """Status und/oder Sichtbarkeit setzen (Superadmin).""" + _require_superadmin(session) + spec = _spec(content_type) + table = spec["table"] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(f"SELECT * FROM {table} WHERE id = %s", (item_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") + current = r2d(row) + + fields: List[str] = [] + params: List[Any] = [] + + if body.status is not None: + st_col = spec.get("status_col") + if not st_col: + raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keinen Status") + st = body.status.strip().lower() + if content_type == "exercise" and st not in _VALID_EXERCISE_STATUS: + raise HTTPException(status_code=400, detail="Ungültiger Übungs-Status") + if content_type == "maturity_model" and st not in _VALID_MATURITY_STATUS: + raise HTTPException(status_code=400, detail="Ungültiger Modell-Status") + if content_type == "media_asset" and st not in _VALID_MEDIA_RIGHTS: + raise HTTPException(status_code=400, detail="Ungültiger Medien-Rechte-Status") + fields.append(f"{st_col} = %s") + params.append(st) + + if body.visibility is not None: + vis_col = spec.get("visibility_col") + if not vis_col: + raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keine Sichtbarkeit") + vis = body.visibility.strip().lower() + if vis not in _VALID_VISIBILITY: + raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit") + if vis == "club" and not current.get(spec.get("club_col") or "club_id"): + raise HTTPException( + status_code=400, + detail="Vereins-Sichtbarkeit erfordert eine Vereinszuordnung (club_id).", + ) + fields.append(f"{vis_col} = %s") + params.append(vis) + + if body.lifecycle_state is not None: + if content_type != "media_asset": + raise HTTPException(status_code=400, detail="Lifecycle nur für Medien") + lc = body.lifecycle_state.strip().lower() + if lc not in _VALID_MEDIA_LIFECYCLE: + raise HTTPException(status_code=400, detail="Ungültiger Lifecycle-Status") + fields.append("lifecycle_state = %s") + params.append(lc) + if lc == "active": + fields.extend( + [ + "trash_soft_at = NULL", + "trash_hidden_at = NULL", + "purge_after_at = NULL", + ] + ) + + if not fields: + raise HTTPException(status_code=400, detail="Keine gültigen Änderungen") + + fields.append("updated_at = NOW()") + params.append(item_id) + cur.execute( + f"UPDATE {table} SET {', '.join(fields)} WHERE id = %s RETURNING id", + tuple(params), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") + conn.commit() + + return {"ok": True, "content_type": content_type, "id": item_id} + + +@router.delete("/items/{content_type}/{item_id}") +def delete_user_content_item( + content_type: ContentType, + item_id: int, + session: dict = Depends(require_auth), +): + """Inhalt endgültig löschen (Superadmin).""" + _require_superadmin(session) + spec = _spec(content_type) + table = spec["table"] + + with get_db() as conn: + cur = get_cursor(conn) + + if content_type == "exercise": + cur.execute("SELECT id FROM exercises WHERE id = %s", (item_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Übung nicht gefunden") + usage_msg = _exercise_delete_usage_message(cur, item_id) + if usage_msg: + raise HTTPException(status_code=409, detail=usage_msg) + cur.execute("DELETE FROM exercises WHERE id = %s", (item_id,)) + conn.commit() + return {"ok": True} + + if content_type == "media_asset": + cur.execute("SELECT id FROM media_assets WHERE id = %s", (item_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + ok = superadmin_hard_delete_media_asset(cur, conn, item_id) + if not ok: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + return {"ok": True} + + cur.execute(f"DELETE FROM {table} WHERE id = %s RETURNING id", (item_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") + conn.commit() + + return {"ok": True, "content_type": content_type, "id": item_id} diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py index 356dd58..7edd264 100644 --- a/backend/scripts/check_access_layer_hints.py +++ b/backend/scripts/check_access_layer_hints.py @@ -26,6 +26,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset( "ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant "ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant "exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant + "admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant "catalogs.py", "skills.py", "maturity_models.py", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 666aa1a..ba38f7f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -57,6 +57,7 @@ const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPa const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage')) const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage')) const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage')) +const AdminUserContentPage = lazy(() => import('./pages/AdminUserContentPage')) const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage')) /** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */ @@ -327,6 +328,14 @@ const appRouter = createBrowserRouter([ ), }, + { + path: 'admin/user-content', + element: ( + + + + ), + }, { path: 'trainer-contexts', element: }, ], }, diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index a189b4b..f2afc9c 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant). @@ -8,6 +8,7 @@ export default function AdminPageNav() { const pages = [ { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/users', label: 'Nutzer', icon: Users }, + { to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }, diff --git a/frontend/src/pages/AdminUserContentPage.jsx b/frontend/src/pages/AdminUserContentPage.jsx new file mode 100644 index 0000000..b740f85 --- /dev/null +++ b/frontend/src/pages/AdminUserContentPage.jsx @@ -0,0 +1,681 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' +import AdminPageNav from '../components/AdminPageNav' + +const VISIBILITY_OPTIONS = [ + { value: 'all', label: 'Alle Sichtbarkeiten' }, + { value: 'private', label: 'Privat' }, + { value: 'club', label: 'Verein' }, + { value: 'official', label: 'Offiziell' }, +] + +const VISIBILITY_LABEL = { + private: 'Privat', + club: 'Verein', + official: 'Offiziell', +} + +const STATUS_LABELS = { + draft: 'Entwurf', + in_review: 'In Prüfung', + approved: 'Freigegeben', + archived: 'Archiviert', + active: 'Aktiv', + legacy_unreviewed: 'Rechte ungeprüft', + declared: 'Rechte erklärt', + blocked: 'Gesperrt', +} + +const LIFECYCLE_LABELS = { + active: 'Aktiv', + trash_soft: 'Papierkorb (soft)', + trash_hidden: 'Papierkorb (hidden)', +} + +function formatDate(value) { + if (!value) return '—' + try { + return new Date(value).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return String(value) + } +} + +function contentLink(item) { + const id = item.id + switch (item.content_type) { + case 'exercise': + return `/exercises/${id}` + case 'training_module': + return `/planning/training-modules/${id}` + case 'framework_program': + return `/planning/framework-programs/${id}` + case 'plan_template': + return `/planning/plan-templates/${id}` + case 'maturity_model': + return '/admin/maturity-models' + case 'media_asset': + return '/media' + default: + return null + } +} + +function statusOptionsForType(meta, contentType) { + const t = meta?.content_types?.find((x) => x.id === contentType) + return (t?.status_values || []).map((v) => ({ + value: v, + label: STATUS_LABELS[v] || v, + })) +} + +function EditModal({ open, item, meta, onClose, onSaved }) { + const [status, setStatus] = useState('') + const [visibility, setVisibility] = useState('') + const [lifecycle, setLifecycle] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!item) return + setStatus(item.status || '') + setVisibility(item.visibility || '') + setLifecycle(item.extra_status || 'active') + setError('') + }, [item]) + + if (!open || !item) return null + + const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type) + const statusOpts = statusOptionsForType(meta, item.content_type) + + const submit = async () => { + setSaving(true) + setError('') + try { + const body = {} + if (typeMeta?.has_status && status && status !== item.status) body.status = status + if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) { + body.visibility = visibility + } + if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) { + body.lifecycle_state = lifecycle + } + if (!Object.keys(body).length) { + onClose() + return + } + await api.patchAdminUserContentItem(item.content_type, item.id, body) + await onSaved() + onClose() + } catch (e) { + setError(e.message || String(e)) + } finally { + setSaving(false) + } + } + + return ( +
+
+

Inhalt bearbeiten

+

+ {item.type_label} · #{item.id} +

+

{item.title || '—'}

+ + {typeMeta?.has_status ? ( +
+ + +
+ ) : null} + + {typeMeta?.has_visibility ? ( +
+ + +
+ ) : null} + + {item.content_type === 'media_asset' ? ( +
+ + +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ) +} + +export default function AdminUserContentPage() { + const { user } = useAuth() + const isSuperadmin = user?.role === 'superadmin' + + const [meta, setMeta] = useState(null) + const [userSummary, setUserSummary] = useState([]) + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [itemsLoading, setItemsLoading] = useState(false) + const [error, setError] = useState('') + + const [profileId, setProfileId] = useState('') + const [contentType, setContentType] = useState('all') + const [visibility, setVisibility] = useState('all') + const [status, setStatus] = useState('') + const [search, setSearch] = useState('') + const [offset, setOffset] = useState(0) + const limit = 50 + + const [editItem, setEditItem] = useState(null) + + const contentTypeOptions = useMemo(() => { + const base = [{ value: 'all', label: 'Alle Typen' }] + for (const t of meta?.content_types || []) { + base.push({ value: t.id, label: t.label }) + } + return base + }, [meta]) + + const statusFilterOptions = useMemo(() => { + if (contentType === 'all') { + return [ + { value: '', label: 'Beliebiger Status' }, + { value: 'draft', label: STATUS_LABELS.draft }, + { value: 'in_review', label: STATUS_LABELS.in_review }, + { value: 'approved', label: STATUS_LABELS.approved }, + { value: 'archived', label: STATUS_LABELS.archived }, + { value: 'active', label: STATUS_LABELS.active }, + { value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed }, + ] + } + return [ + { value: '', label: 'Beliebiger Status' }, + ...statusOptionsForType(meta, contentType), + ] + }, [contentType, meta]) + + const loadSummary = useCallback(async () => { + const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()]) + setMeta(m) + setUserSummary(Array.isArray(s) ? s : []) + }, []) + + const loadItems = useCallback(async (forcedOffset) => { + setItemsLoading(true) + try { + const params = { + content_type: contentType, + visibility, + limit, + offset: forcedOffset ?? offset, + } + if (profileId) params.profile_id = Number(profileId) + if (status) params.status = status + if (search.trim()) params.search = search.trim() + const res = await api.listAdminUserContentItems(params) + setItems(Array.isArray(res?.items) ? res.items : []) + setTotal(Number(res?.total) || 0) + } finally { + setItemsLoading(false) + } + }, [contentType, visibility, status, search, profileId, offset]) + + useEffect(() => { + if (!isSuperadmin) return + let cancelled = false + ;(async () => { + setLoading(true) + setError('') + try { + await loadSummary() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, loadSummary]) + + useEffect(() => { + if (!isSuperadmin) return + let cancelled = false + ;(async () => { + try { + await loadItems() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, loadItems]) + + const applyFilters = () => { + setOffset(0) + loadItems(0) + } + + const handleDelete = async (item) => { + const label = item.title || `${item.type_label} #${item.id}` + if ( + !confirm( + `„${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`, + ) + ) { + return + } + try { + await api.deleteAdminUserContentItem(item.content_type, item.id) + await Promise.all([loadItems(), loadSummary()]) + } catch (e) { + alert(e.message || String(e)) + } + } + + if (!isSuperadmin) return + + return ( +
+ +
+

Nutzer-Inhalte

+

+ Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte + löschen (nur Superadmin). +

+
+ + {error ? ( +
+

{error}

+
+ ) : null} + + {loading ? ( +
+ ) : ( + <> +
+

Aktivität je Nutzer

+ {userSummary.length === 0 ? ( +

+ Noch keine nutzerangelegten Inhalte. +

+ ) : ( +
+ + + + + + {(meta?.content_types || []).map((t) => ( + + ))} + + + + + {userSummary.map((u) => ( + + + + {(meta?.content_types || []).map((t) => ( + + ))} + + + ))} + +
NutzerGesamt{t.label}
+
{u.name || `Profil #${u.id}`}
+
+ {u.email || '—'} +
+
+ {u.total_count} + {u.counts_by_type?.[t.id] ?? 0} + +
+
+ )} +
+ +
+

Inhalte

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyFilters()} + placeholder="Titel oder Dateiname…" + /> + +
+
+
+ + {itemsLoading ? ( +
+ ) : items.length === 0 ? ( +

Keine Inhalte für die aktuellen Filter.

+ ) : ( +
+ + + + + + + + + + + + + + {items.map((item) => { + const href = contentLink(item) + return ( + + + + + + + + + + ) + })} + +
TypTitelNutzerSichtbarkeitStatusAktualisiert
{item.type_label} +
+ {href ? ( + {item.title || '—'} + ) : ( + item.title || '—' + )} +
+
+ #{item.id} + {item.club_name ? ` · ${item.club_name}` : ''} +
+
+
{item.profile_name || '—'}
+
+ {item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')} +
+
+ {item.visibility ? ( + + {VISIBILITY_LABEL[item.visibility] || item.visibility} + + ) : ( + '—' + )} + + {item.status ? STATUS_LABELS[item.status] || item.status : '—'} + {item.extra_status && item.extra_status !== 'active' ? ( +
+ {LIFECYCLE_LABELS[item.extra_status] || item.extra_status} +
+ ) : null} +
{formatDate(item.updated_at)} +
+ + +
+
+
+ )} + + {total > limit ? ( +
+ + {total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '} + {Math.ceil(total / limit)} + +
+ + +
+
+ ) : null} +
+ + )} + + setEditItem(null)} + onSaved={async () => { + await Promise.all([loadItems(), loadSummary()]) + }} + /> +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 29c2745..57ed2bb 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -50,6 +50,39 @@ export async function listAdminUsers() { return request('/api/admin/users') } +/** Superadmin: Metadaten zu nutzerangelegten Inhaltstypen. */ +export async function getAdminUserContentMeta() { + return request('/api/admin/user-content/meta') +} + +/** Superadmin: Aktivitätsübersicht je Nutzer (Anzahl Inhalte). */ +export async function listAdminUserContentSummary() { + return request('/api/admin/user-content/users-summary') +} + +/** Superadmin: Inhalte aller Nutzer (inkl. privat) — filterbar. */ +export async function listAdminUserContentItems(params = {}) { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)) + } + const q = qs.toString() + return request(`/api/admin/user-content/items${q ? `?${q}` : ''}`) +} + +/** Superadmin: Status/Sichtbarkeit eines Inhalts setzen. */ +export async function patchAdminUserContentItem(contentType, itemId, body) { + return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +/** Superadmin: Inhalt löschen. */ +export async function deleteAdminUserContentItem(contentType, itemId) { + return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { method: 'DELETE' }) +} + /** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */ export async function getPlatformMediaStorage() { return request('/api/admin/platform-media-storage') @@ -819,6 +852,11 @@ export const api = { getCurrentProfile, listProfiles, listAdminUsers, + getAdminUserContentMeta, + listAdminUserContentSummary, + listAdminUserContentItems, + patchAdminUserContentItem, + deleteAdminUserContentItem, updateProfile, managementPasswordReset, changePassword,