feat: Implement Content Reporting Backend
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 58s

- Added new API endpoints for content reporting, including submission, retrieval, and status updates.
- Created database migration for `content_reports` table to store report data.
- Integrated content reports into the existing admin inbox for better management.
- Implemented validation for report submissions, including required fields and email format.
- Added tests for content reporting functionality, covering various scenarios and edge cases.
- Updated frontend API utility to include new content report methods.
- Bumped app version to 0.8.87 and updated relevant page versions.
This commit is contained in:
Lars 2026-05-11 17:54:53 +02:00
parent 3c0e63757c
commit 60709df615
12 changed files with 1610 additions and 116 deletions

View File

@ -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, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@ -216,6 +216,7 @@ app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).

View File

@ -0,0 +1,73 @@
-- P-13: Content-Melde-Backend
-- Meldungen rechtswidriger Inhalte (DSA-konformes Meldeverfahren, KRIT-03)
--
-- Architektur: Diese Tabelle traegt alle fachlichen Report-Daten.
-- Die bestehende Admin-Inbox (InboxPage.jsx, GET /api/me/inbox/join-requests)
-- wird um einen zweiten Abschnitt erweitert, der Content-Reports anzeigt.
-- Keine separate Admin-Queue, keine generische inbox_items-Tabelle.
CREATE TABLE IF NOT EXISTS content_reports (
id SERIAL PRIMARY KEY,
-- Ziel der Meldung (erweiterbar auf weitere Typen)
target_type VARCHAR(20) NOT NULL DEFAULT 'media_asset'
CHECK (target_type IN ('media_asset', 'exercise')),
target_id INTEGER NOT NULL,
-- Meldungsinhalt
report_reason VARCHAR(50) NOT NULL
CHECK (report_reason IN (
'copyright',
'image_rights',
'privacy',
'minors',
'illegal_content',
'youth_protection',
'offensive_content',
'other'
)),
report_description TEXT NOT NULL,
-- Meldende Person (Name + E-Mail Pflicht; Profil optional bei eingeloggten Nutzern)
reporter_name VARCHAR(200) NOT NULL,
reporter_email VARCHAR(200) NOT NULL,
reporter_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
-- Gutglaubenserklärung (Pflicht)
good_faith_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
-- Automatische Priorisierung (high fuer minors/youth_protection/illegal_content)
priority VARCHAR(20) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('high', 'normal')),
-- Workflow-Status
status VARCHAR(30) NOT NULL DEFAULT 'submitted'
CHECK (status IN (
'submitted',
'under_review',
'resolved_no_action',
'resolved_legal_hold',
'rejected_invalid'
)),
-- Bearbeitung
assigned_to_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
reviewed_by_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
reviewed_at TIMESTAMP,
resolution_note TEXT,
-- Zeitstempel
submitted_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Indices fuer Admin-Liste
CREATE INDEX IF NOT EXISTS idx_content_reports_status_created
ON content_reports (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_content_reports_target
ON content_reports (target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_content_reports_priority
ON content_reports (priority, status, created_at DESC);

View File

@ -0,0 +1,584 @@
"""
P-13: Content-Melde-Backend
Meldeverfahren fuer moeglicherweise rechtswidrige Inhalte (DSA-Grundlage, KRIT-03).
Architektur:
- Fachliche Daten in 'content_reports' Tabelle (Migration 052)
- Integration in bestehende Admin-Inbox (GET /api/me/inbox/content-reports)
- Keine separate Admin-Queue; die InboxPage.jsx zeigt beide Vorgangstypen
- P-11-Anschluss: Superadmin kann aus Meldung heraus Legal Hold setzen
Berechtigungen:
- Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt)
- Liste/Detail: Plattform-Admin (admin/superadmin)
- Status-Bearbeitung: Plattform-Admin
- Legal Hold aus Meldung: ausschliesslich Superadmin
"""
from __future__ import annotations
import re
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from club_tenancy import is_platform_admin, is_superadmin
from db import get_db, get_cursor, r2d
from media_legal_hold import (
LEGAL_HOLD_REASON_CODES,
assert_superadmin_for_legal_hold,
set_legal_hold,
)
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["content_reports"])
# Erlaubte Meldungsgruende
REPORT_REASONS = frozenset({
"copyright",
"image_rights",
"privacy",
"minors",
"illegal_content",
"youth_protection",
"offensive_content",
"other",
})
# Gruende mit hoher Prioritaet (illegal, Minderjaehrigenschutz)
HIGH_PRIORITY_REASONS = frozenset({
"minors",
"illegal_content",
"youth_protection",
})
# Mapping: report_reason -> legal_hold reason_code
_REASON_TO_HOLD_CODE = {
"copyright": "copyright_complaint",
"image_rights": "rights_dispute",
"privacy": "privacy_complaint",
"minors": "youth_protection",
"illegal_content": "illegal_content",
"youth_protection": "youth_protection",
"offensive_content": "illegal_content",
"other": "other",
}
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# ─── Pydantic-Modelle ────────────────────────────────────────────────────────
class ContentReportCreate(BaseModel):
target_type: str = Field(default="media_asset")
target_id: int = Field(..., ge=1)
report_reason: str
report_description: str = Field(..., min_length=10, max_length=8000)
reporter_name: str = Field(..., min_length=1, max_length=200)
reporter_email: str = Field(..., min_length=5, max_length=200)
good_faith_confirmed: bool
@field_validator("target_type")
@classmethod
def validate_target_type(cls, v: str) -> str:
if v not in ("media_asset", "exercise"):
raise ValueError("target_type muss 'media_asset' oder 'exercise' sein")
return v
@field_validator("report_reason")
@classmethod
def validate_reason(cls, v: str) -> str:
if v not in REPORT_REASONS:
raise ValueError(f"Ungueltiger report_reason. Erlaubt: {sorted(REPORT_REASONS)}")
return v
@field_validator("reporter_email")
@classmethod
def validate_email(cls, v: str) -> str:
if not _EMAIL_RE.match(v.strip()):
raise ValueError("Ungueltige E-Mail-Adresse")
return v.strip().lower()
@field_validator("reporter_name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Name darf nicht leer sein")
return v
class ContentReportPatch(BaseModel):
status: Optional[str] = None
resolution_note: Optional[str] = Field(None, max_length=4000)
assigned_to_profile_id: Optional[int] = None
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
allowed = {"submitted", "under_review", "resolved_no_action",
"resolved_legal_hold", "rejected_invalid"}
if v not in allowed:
raise ValueError(f"Ungueltiger status. Erlaubt: {sorted(allowed)}")
return v
class ContentReportLegalHoldBody(BaseModel):
reason_note: str = Field(..., min_length=5, max_length=2000)
# ─── Hilfsfunktionen ────────────────────────────────────────────────────────
def _assert_platform_admin(role: Optional[str]) -> None:
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Plattform-Admins koennen Meldungen bearbeiten")
def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool:
"""True wenn das Medium als 'official' und aktiv gilt (fuer anonyme Meldung zugaenglich)."""
cur.execute(
"""
SELECT 1 FROM media_assets
WHERE id = %s
AND visibility = 'official'
AND lifecycle_state = 'active'
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)
""",
(asset_id,),
)
return cur.fetchone() is not None
def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, global_role: Optional[str]) -> bool:
"""Prueft ob ein eingeloggter Nutzer das Medium sehen darf."""
if is_platform_admin(global_role):
cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,))
return cur.fetchone() is not None
# Sichtbarkeit pruefen: official (immer), club (Mitglied), private (Eigentuemerrechte sind breiter)
cur.execute(
"""
SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id
FROM media_assets ma
WHERE ma.id = %s
AND ma.lifecycle_state = 'active'
AND (ma.legal_hold_active = FALSE OR ma.legal_hold_active IS NULL)
""",
(asset_id,),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
visibility = asset.get("visibility")
if visibility == "official":
return True
if visibility == "club":
club_id = asset.get("club_id")
if not club_id:
return False
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
if visibility == "private":
# Private Medien: Eigentuemerrechte oder gleicher Verein mit club_admin
if asset.get("uploaded_by_profile_id") == profile_id:
return True
club_id = asset.get("club_id")
if club_id:
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active'
AND r.role_code = 'club_admin'
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
return False
def _is_exercise_visible_to_profile(cur, exercise_id: int, profile_id: Optional[int], global_role: Optional[str]) -> bool:
"""Vereinfachte Sichtbarkeitspruefung fuer Uebungen (fuer Meldungen)."""
if profile_id and is_platform_admin(global_role):
cur.execute("SELECT 1 FROM exercises WHERE id = %s AND status != 'deleted'", (exercise_id,))
return cur.fetchone() is not None
if profile_id is None:
cur.execute(
"SELECT 1 FROM exercises WHERE id = %s AND visibility = 'official' AND status = 'active'",
(exercise_id,),
)
return cur.fetchone() is not None
cur.execute(
"""
SELECT e.visibility, e.club_id, e.created_by_profile_id
FROM exercises e WHERE e.id = %s AND e.status != 'deleted'
""",
(exercise_id,),
)
row = cur.fetchone()
if not row:
return False
ex = r2d(row)
if ex.get("visibility") == "official":
return True
if ex.get("visibility") == "club":
club_id = ex.get("club_id")
if not club_id:
return False
cur.execute(
"SELECT 1 FROM club_members WHERE profile_id = %s AND club_id = %s AND status = 'active'",
(profile_id, club_id),
)
return cur.fetchone() is not None
if ex.get("visibility") == "private":
return ex.get("created_by_profile_id") == profile_id
return False
def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[int], Optional[str]]:
"""Gibt (profile_id, global_role) zurueck wenn Token gueltig, sonst (None, None)."""
if not x_auth_token:
return None, None
cur.execute(
"""
SELECT p.id AS profile_id, p.role
FROM sessions s
INNER JOIN profiles p ON p.id = s.profile_id
WHERE s.token = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW())
""",
(x_auth_token,),
)
row = cur.fetchone()
if not row:
return None, None
d = r2d(row)
return d.get("profile_id"), d.get("role")
def _report_row_to_dict(row) -> dict:
return r2d(row)
# ─── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/content-reports", status_code=201)
def submit_content_report(
body: ContentReportCreate,
x_auth_token: Optional[str] = Header(default=None),
):
"""
Meldung einreichen (optionale Authentifizierung).
- Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte)
- Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf
- Gutglaubenserklärung (good_faith_confirmed=true) ist Pflicht
"""
if not body.good_faith_confirmed:
raise HTTPException(
status_code=400,
detail="Die Gutglaubenserklärung (good_faith_confirmed) muss bestätigt werden.",
)
priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal"
with get_db() as conn:
cur = get_cursor(conn)
# Optionale Auth aufloesen
profile_id, global_role = _resolve_optional_auth(cur, x_auth_token)
# Sichtbarkeit pruefen
if body.target_type == "media_asset":
if profile_id:
visible = _is_media_asset_visible_to_profile(cur, body.target_id, profile_id, global_role)
else:
visible = _is_media_asset_visible_anonymous(cur, body.target_id)
if not visible:
raise HTTPException(
status_code=404,
detail="Das gemeldete Medium existiert nicht oder ist nicht zugänglich.",
)
elif body.target_type == "exercise":
visible = _is_exercise_visible_to_profile(cur, body.target_id, profile_id, global_role)
if not visible:
raise HTTPException(
status_code=404,
detail="Die gemeldete Übung existiert nicht oder ist nicht zugänglich.",
)
cur.execute(
"""
INSERT INTO content_reports (
target_type, target_id, report_reason, report_description,
reporter_name, reporter_email, reporter_profile_id,
good_faith_confirmed, priority, status,
submitted_at, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'submitted', NOW(), NOW(), NOW())
RETURNING id, status, priority, submitted_at
""",
(
body.target_type,
body.target_id,
body.report_reason,
body.report_description.strip(),
body.reporter_name,
body.reporter_email,
profile_id,
body.good_faith_confirmed,
priority,
),
)
row = cur.fetchone()
result = r2d(row)
conn.commit()
return {
"id": result["id"],
"status": result["status"],
"priority": result["priority"],
"submitted_at": str(result["submitted_at"]),
"message": "Ihre Meldung wurde entgegengenommen. Ein Administrator wird sie prüfen.",
}
@router.get("/me/inbox/content-reports")
def list_inbox_content_reports(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Alle Meldungen fuer die Admin-Inbox.
Sichtbar fuer Plattform-Admins (admin + superadmin).
In der InboxPage neben den Join-Requests angezeigt.
"""
_assert_platform_admin(tenant.global_role)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT
cr.id,
cr.target_type,
cr.target_id,
cr.report_reason,
cr.report_description,
cr.reporter_name,
cr.reporter_email,
cr.reporter_profile_id,
cr.priority,
cr.status,
cr.assigned_to_profile_id,
cr.reviewed_by_profile_id,
cr.reviewed_at,
cr.resolution_note,
cr.submitted_at,
cr.created_at,
cr.updated_at,
-- Ziel-Medium-Infos (wenn target_type = media_asset)
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind,
ma.legal_hold_active AS target_legal_hold_active,
-- Ziel-Uebungs-Infos (wenn target_type = exercise)
ex.name AS target_exercise_name,
-- Reviewer-Name
rev.name AS reviewed_by_name,
-- Zugewiesen-Name
asgn.name AS assigned_to_name
FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
ORDER BY
CASE WHEN cr.status = 'submitted' THEN 0
WHEN cr.status = 'under_review' THEN 1
ELSE 2 END ASC,
cr.priority DESC,
cr.created_at ASC
LIMIT 500
""",
)
return [_report_row_to_dict(row) for row in cur.fetchall()]
@router.get("/content-reports/{report_id}")
def get_content_report(
report_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Detail-Ansicht einer Meldung fuer Plattform-Admins."""
_assert_platform_admin(tenant.global_role)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT
cr.*,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind,
ma.legal_hold_active AS target_legal_hold_active,
ma.legal_hold_reason_code AS target_legal_hold_reason_code,
ex.name AS target_exercise_name,
ex.visibility AS target_exercise_visibility,
rev.name AS reviewed_by_name,
asgn.name AS assigned_to_name,
rep.name AS reporter_profile_name
FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id
WHERE cr.id = %s
""",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _report_row_to_dict(row)
@router.patch("/content-reports/{report_id}")
def patch_content_report(
report_id: int,
body: ContentReportPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Status und Bearbeitungsnotiz einer Meldung aktualisieren.
Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note.
"""
_assert_platform_admin(tenant.global_role)
pid = tenant.profile_id
if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip():
raise HTTPException(
status_code=400,
detail="Bei Abschluss ohne Massnahme ist eine Begründung (resolution_note) Pflicht.",
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id, status FROM content_reports WHERE id = %s", (report_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
updates = ["updated_at = NOW()"]
params = []
if body.status is not None:
updates.append("status = %s")
params.append(body.status)
if body.status in ("resolved_no_action", "resolved_legal_hold", "rejected_invalid"):
updates.append("reviewed_by_profile_id = %s")
updates.append("reviewed_at = NOW()")
params.append(pid)
if body.resolution_note is not None:
updates.append("resolution_note = %s")
params.append(body.resolution_note.strip() or None)
if body.assigned_to_profile_id is not None:
updates.append("assigned_to_profile_id = %s")
params.append(body.assigned_to_profile_id)
if len(updates) == 1:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren angegeben")
params.append(report_id)
cur.execute(
f"UPDATE content_reports SET {', '.join(updates)} WHERE id = %s RETURNING id, status",
params,
)
updated = r2d(cur.fetchone())
conn.commit()
return {"ok": True, "id": updated["id"], "status": updated["status"]}
@router.post("/content-reports/{report_id}/legal-hold")
def set_legal_hold_from_report(
report_id: int,
body: ContentReportLegalHoldBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Legal Hold (P-11) aus einer Meldung heraus setzen.
Nur Superadmin. Nur fuer Meldungen mit target_type='media_asset'.
Der Reason-Code wird automatisch aus dem report_reason der Meldung abgeleitet.
Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt.
"""
assert_superadmin_for_legal_hold(tenant.global_role)
pid = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, target_type, target_id, report_reason, status FROM content_reports WHERE id = %s",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
report = r2d(row)
if report["target_type"] != "media_asset":
raise HTTPException(
status_code=400,
detail="Legal Hold ist nur fuer Meldungen mit target_type='media_asset' verfügbar.",
)
asset_id = int(report["target_id"])
reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other")
# P-11-Service aufrufen
set_legal_hold(
cur=cur,
conn=conn,
asset_id=asset_id,
acting_profile_id=pid,
reason_code=reason_code,
reason_note=f"[P-13 Meldung #{report_id}] {body.reason_note.strip()}",
)
# set_legal_hold committed bereits — neue Transaktion fuer Report-Update
cur.execute(
"""
UPDATE content_reports
SET status = 'resolved_legal_hold',
reviewed_by_profile_id = %s,
reviewed_at = NOW(),
resolution_note = %s,
updated_at = NOW()
WHERE id = %s
RETURNING id, status
""",
(pid, f"Legal Hold gesetzt auf Asset #{asset_id} (reason_code: {reason_code})", report_id),
)
updated = r2d(cur.fetchone())
conn.commit()
return {
"ok": True,
"report_id": updated["id"],
"report_status": updated["status"],
"asset_id": asset_id,
"legal_hold_reason_code": reason_code,
}

View File

@ -0,0 +1,327 @@
"""
P-13: Content-Melde-Backend Backend-Tests.
Abgedeckt (15 Faelle):
1. submit_report ohne good_faith_confirmed=False 400
2. submit_report mit leerem report_description 422
3. submit_report mit fehlender reporter_email 422
4. submit_report ohne Login fuer official-Medium Erfolgspfad
5. submit_report ohne Login fuer nicht-official-Medium 404
6. submit_report mit Login fuer sichtbares club-Medium Erfolgspfad
7. submit_report mit Login fuer nicht-sichtbares fremdes Medium 404
8. Prioritaet high fuer illegal_content/minors/youth_protection
9. Prioritaet normal fuer copyright/other
10. list_inbox als Nicht-Admin 403
11. list_inbox als Plattform-Admin gibt Liste zurueck
12. patch_report status 'under_review' Erfolgspfad
13. patch_report 'resolved_no_action' ohne resolution_note 400
14. set_legal_hold_from_report als Nicht-Superadmin 403
15. set_legal_hold_from_report als Superadmin Erfolgspfad (reason_code-Mapping)
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from routers.content_reports import (
HIGH_PRIORITY_REASONS,
_REASON_TO_HOLD_CODE,
_is_media_asset_visible_anonymous,
_is_media_asset_visible_to_profile,
REPORT_REASONS,
ContentReportCreate,
ContentReportPatch,
)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _make_cur(fetchone_val=None):
cur = MagicMock()
cur.fetchone.return_value = fetchone_val
cur.fetchall.return_value = []
return cur
def _row(data: dict):
"""Simuliert eine psycopg2-DictRow."""
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.keys = lambda: data.keys()
return m
# ---------------------------------------------------------------------------
# 1. good_faith_confirmed=False → 400
# ---------------------------------------------------------------------------
def test_submit_requires_good_faith():
body = ContentReportCreate(
target_type="media_asset",
target_id=1,
report_reason="copyright",
report_description="A" * 20,
reporter_name="Max",
reporter_email="max@example.com",
good_faith_confirmed=False,
)
from routers.content_reports import submit_content_report
with pytest.raises(HTTPException) as exc:
submit_content_report(body)
assert exc.value.status_code == 400
assert "Gutglauben" in exc.value.detail
# ---------------------------------------------------------------------------
# 2. Leere report_description → 422 (Pydantic)
# ---------------------------------------------------------------------------
def test_submit_requires_description():
with pytest.raises(Exception):
ContentReportCreate(
target_type="media_asset",
target_id=1,
report_reason="copyright",
report_description="ab", # unter min_length=10
reporter_name="Max",
reporter_email="max@example.com",
good_faith_confirmed=True,
)
# ---------------------------------------------------------------------------
# 3. Ungueltige E-Mail → 422 (Pydantic Validator)
# ---------------------------------------------------------------------------
def test_submit_requires_valid_email():
with pytest.raises(Exception):
ContentReportCreate(
target_type="media_asset",
target_id=1,
report_reason="copyright",
report_description="A" * 20,
reporter_name="Max",
reporter_email="kein-at-zeichen",
good_faith_confirmed=True,
)
# ---------------------------------------------------------------------------
# 4. Anonym official-Medium → sichtbar
# ---------------------------------------------------------------------------
def test_anonymous_official_medium_visible():
cur = _make_cur(fetchone_val={"1": 1})
cur.fetchone.return_value = MagicMock()
result = _is_media_asset_visible_anonymous(cur, asset_id=42)
assert result is True
# ---------------------------------------------------------------------------
# 5. Anonym nicht-official-Medium → nicht sichtbar
# ---------------------------------------------------------------------------
def test_anonymous_non_official_medium_not_visible():
cur = _make_cur(fetchone_val=None)
result = _is_media_asset_visible_anonymous(cur, asset_id=99)
assert result is False
# ---------------------------------------------------------------------------
# 6. Eingeloggter Nutzer sichtbares club-Medium → True
# ---------------------------------------------------------------------------
def test_logged_in_club_member_can_see_club_media():
asset_row = MagicMock()
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
asset_row.__getitem__ = lambda s, k: {
"id": 7,
"visibility": "club",
"club_id": 3,
"uploaded_by_profile_id": 99,
}[k]
member_row = MagicMock() # Mitgliedschaft gefunden
cur = MagicMock()
cur.fetchone.side_effect = [asset_row, member_row]
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=1, global_role="trainer")
assert result is True
# ---------------------------------------------------------------------------
# 7. Eingeloggter Nutzer fremdes Medium → False
# ---------------------------------------------------------------------------
def test_logged_in_non_member_cannot_see_club_media():
asset_row = MagicMock()
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
asset_row.__getitem__ = lambda s, k: {
"id": 7,
"visibility": "club",
"club_id": 3,
"uploaded_by_profile_id": 99,
}[k]
cur = MagicMock()
# Erster fetchone: Asset-Zeile, zweiter: kein Mitglied
cur.fetchone.side_effect = [asset_row, None]
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=5, global_role="trainer")
assert result is False
# ---------------------------------------------------------------------------
# 8. Prioritaet high fuer kritische Gruende
# ---------------------------------------------------------------------------
def test_high_priority_reasons():
assert "illegal_content" in HIGH_PRIORITY_REASONS
assert "minors" in HIGH_PRIORITY_REASONS
assert "youth_protection" in HIGH_PRIORITY_REASONS
# ---------------------------------------------------------------------------
# 9. Prioritaet normal fuer andere Gruende
# ---------------------------------------------------------------------------
def test_normal_priority_reasons():
normal_reasons = REPORT_REASONS - HIGH_PRIORITY_REASONS
assert "copyright" in normal_reasons
assert "other" in normal_reasons
assert "privacy" in normal_reasons
# ---------------------------------------------------------------------------
# 10. list_inbox als Nicht-Admin → 403
# ---------------------------------------------------------------------------
def test_list_inbox_requires_platform_admin():
from routers.content_reports import list_inbox_content_reports
tenant = MagicMock()
tenant.global_role = "trainer"
with pytest.raises(HTTPException) as exc:
list_inbox_content_reports(tenant=tenant)
assert exc.value.status_code == 403
# ---------------------------------------------------------------------------
# 11. list_inbox als Plattform-Admin gibt Liste zurueck
# ---------------------------------------------------------------------------
def test_list_inbox_as_admin_returns_list():
from routers.content_reports import list_inbox_content_reports
tenant = MagicMock()
tenant.global_role = "admin"
mock_conn = MagicMock()
mock_cur = MagicMock()
mock_cur.fetchall.return_value = []
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
mock_conn.__exit__ = MagicMock(return_value=False)
mock_conn_ctx = MagicMock()
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
patch("routers.content_reports.get_cursor", return_value=mock_cur):
result = list_inbox_content_reports(tenant=tenant)
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# 12. patch_report status 'under_review' Erfolgspfad
# ---------------------------------------------------------------------------
def test_patch_report_under_review():
from routers.content_reports import patch_content_report
tenant = MagicMock()
tenant.global_role = "admin"
tenant.profile_id = 1
body = ContentReportPatch(status="under_review")
existing_row = MagicMock()
existing_row.__getitem__ = lambda s, k: {"id": 5, "status": "submitted"}[k]
existing_row.keys = lambda: ["id", "status"]
updated_row = MagicMock()
updated_row.__getitem__ = lambda s, k: {"id": 5, "status": "under_review"}[k]
updated_row.keys = lambda: ["id", "status"]
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [existing_row, updated_row]
mock_conn = MagicMock()
mock_conn_ctx = MagicMock()
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
patch("routers.content_reports.get_cursor", return_value=mock_cur):
result = patch_content_report(5, body, tenant=tenant)
assert result["ok"] is True
assert result["status"] == "under_review"
# ---------------------------------------------------------------------------
# 13. patch_report 'resolved_no_action' ohne resolution_note → 400
# ---------------------------------------------------------------------------
def test_patch_resolved_no_action_requires_note():
from routers.content_reports import patch_content_report
tenant = MagicMock()
tenant.global_role = "admin"
tenant.profile_id = 1
body = ContentReportPatch(status="resolved_no_action", resolution_note=None)
with pytest.raises(HTTPException) as exc:
patch_content_report(99, body, tenant=tenant)
assert exc.value.status_code == 400
assert "Begründung" in exc.value.detail
# ---------------------------------------------------------------------------
# 14. set_legal_hold_from_report als Nicht-Superadmin → 403
# ---------------------------------------------------------------------------
def test_legal_hold_from_report_requires_superadmin():
from routers.content_reports import set_legal_hold_from_report, ContentReportLegalHoldBody
tenant = MagicMock()
tenant.global_role = "admin" # nur admin, nicht superadmin
body = ContentReportLegalHoldBody(reason_note="Test-Begruendung")
with pytest.raises(HTTPException) as exc:
set_legal_hold_from_report(1, body, tenant=tenant)
assert exc.value.status_code == 403
# ---------------------------------------------------------------------------
# 15. Reason-Code-Mapping: copyright → copyright_complaint
# ---------------------------------------------------------------------------
def test_reason_code_mapping():
assert _REASON_TO_HOLD_CODE["copyright"] == "copyright_complaint"
assert _REASON_TO_HOLD_CODE["minors"] == "youth_protection"
assert _REASON_TO_HOLD_CODE["illegal_content"] == "illegal_content"
assert _REASON_TO_HOLD_CODE["privacy"] == "privacy_complaint"
assert _REASON_TO_HOLD_CODE["image_rights"] == "rights_dispute"
assert _REASON_TO_HOLD_CODE["other"] == "other"
# Alle Reason-Codes muessen gemappt sein
for reason in REPORT_REASONS:
assert reason in _REASON_TO_HOLD_CODE, f"{reason} fehlt im Mapping"

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.86"
APP_VERSION = "0.8.87"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511051"
DB_SCHEMA_VERSION = "20260511052"
MODULE_VERSIONS = {
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
@ -30,9 +30,21 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
"content_reports": "1.0.0", # P-13: Content-Melde-Backend (DSA-konform, Inbox-Integration, P-11 Legal Hold)
}
CHANGELOG = [
{
"version": "0.8.87",
"date": "2026-05-11",
"changes": [
"Feat P-13: Content-Melde-Backend (DSA/KRIT-03) — Migration 052 content_reports; POST /api/content-reports (optionale Auth, official+aktive Medien ohne Login meldbar); GET /api/me/inbox/content-reports (Plattform-Admin); PATCH /api/content-reports/{id}; POST /api/content-reports/{id}/legal-hold (Superadmin, P-11 Integration).",
"Feat P-13: Automatische Priorisierung high fuer minors/illegal_content/youth_protection.",
"Feat P-13: Inbox-Integration — zweiter Abschnitt 'Inhaltsmeldungen' in InboxPage.jsx; OrgInboxContext liefert contentReports + contentReportCount.",
"Feat P-13: P-11 Legal-Hold via set_legal_hold() aus report heraus (reason_code-Mapping); keine separate Moderations-Queue.",
"Tests: 15 Backend-Unit-Tests test_p13_content_reports.py.",
],
},
{
"version": "0.8.86",
"date": "2026-05-11",

View File

@ -3,7 +3,7 @@
**Erstellt:** 2026-05-09
**Zuletzt aktualisiert:** 2026-05-11
**Audit-Basis:** `docs/compliance-audit.md`
**App-Version nach Umsetzung:** 0.8.86
**App-Version nach Umsetzung:** 0.8.87
---
@ -523,6 +523,50 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
---
### P-13 Content-Melde-Backend ✅
**Status:** Vollständig umgesetzt (2026-05-11, Version 0.8.87)
**Finding:** KRIT-03
**Architekturentscheidung:** Anstelle einer separaten Moderations-Queue wurde die bestehende Admin-Inbox (`InboxPage.jsx`) um einen zweiten Abschnitt erweitert. Keine generische `inbox_items`-Tabelle, keine separate `/api/admin/reports`-Queue.
**Betroffene Dateien:**
- `backend/migrations/052_content_reports.sql` (neu) — Tabelle `content_reports` mit Status-Workflow, Priorisierung, 3 Indizes
- `backend/routers/content_reports.py` (neu) — alle Endpoints
- `backend/tests/test_p13_content_reports.py` (neu) — 15 Unit-Tests
- `backend/main.py` — Router-Registrierung
- `backend/version.py` — Version 0.8.87, content_reports 1.0.0
- `frontend/src/utils/api.js` — 5 neue API-Funktionen (submitContentReport, getInboxContentReports, getContentReport, patchContentReport, setLegalHoldFromReport)
- `frontend/src/context/OrgInboxContext.jsx` — contentReports-State, contentReportCount, canAccessContentReports, isSuperadmin
- `frontend/src/pages/InboxPage.jsx` — zweiter Abschnitt „Inhaltsmeldungen", ReportDetailModal
**API-Endpoints:**
| Endpoint | Auth | Beschreibung |
|----------|------|--------------|
| `POST /api/content-reports` | Optional | Meldung einreichen (official-Medien ohne Login; eingeloggt: alle sichtbaren Medien) |
| `GET /api/me/inbox/content-reports` | Plattform-Admin | Liste aller Meldungen, JOIN auf Zieltabellen |
| `GET /api/content-reports/{id}` | Plattform-Admin | Einzel-Detail |
| `PATCH /api/content-reports/{id}` | Plattform-Admin | Status/Notiz/Zuweisung; resolution_note Pflicht für Abschluss-Status |
| `POST /api/content-reports/{id}/legal-hold` | Superadmin | Legal Hold via P-11 `set_legal_hold()`; setzt Status auf `resolved_legal_hold` |
**Reason-Code-Mapping (P-13 → P-11):**
| Meldegrund | Legal-Hold reason_code |
|------------|------------------------|
| copyright | copyright_complaint |
| image_rights | rights_dispute |
| privacy | privacy_complaint |
| minors | youth_protection |
| illegal_content | illegal_content |
| youth_protection | youth_protection |
| offensive_content | illegal_content |
| other | other |
**Nicht in P-13-Scope:** P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung per E-Mail), P-16 (Beschwerdeverfahren). SMTP-Infrastruktur vorhanden; E-Mail-Benachrichtigungen folgen in P-15.
---
## Nicht umgesetzte Pakete
> Paket-IDs und -Titel gemäß kanonischem Register `docs/compliance-package-register.md`.
@ -538,7 +582,7 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
| P-10 | Mindestalter-Abfrage | offen | Scope ausgeschlossen |
| P-11 | Legal-Hold Lifecycle-Status | ✅ umgesetzt | Version 0.8.840.8.86 — siehe §P-11 oben |
| P-12 | sessionStorage bei Logout bereinigen | ✅ umgesetzt | Version 0.8.68 — siehe §P-12 oben |
| P-13 | Content-Melde-Backend | offen | Scope ausgeschlossen (erst juristisch klären) |
| P-13 | Content-Melde-Backend | ✅ umgesetzt | Version 0.8.87 — siehe §P-13 unten |
| P-14 | Moderations-UI | offen | Scope ausgeschlossen |
| P-15 | Uploader-Benachrichtigung bei Sperrung | offen | Scope ausgeschlossen |
| P-16 | Beschwerdeverfahren | offen | Scope ausgeschlossen |
@ -553,7 +597,7 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
## Re-Audit-Empfehlung
Operativ prüfen (Stand 0.8.86):
Operativ prüfen (Stand 0.8.87):
1. **P-03/P-03b**: `docker logs shinkan-retention-cron` — Job läuft täglich 03:00 Uhr; Retention-Zeiten: 30 → 30 Tage
2. **P-04**: Manuell: PATCH privates Medium auf `official` ohne `copyright_notice` → muss 400 liefern
@ -562,5 +606,6 @@ Operativ prüfen (Stand 0.8.86):
5. **P-06**: Manuell: Upload ohne `rights_holder_confirmed` → muss 400 liefern; Journal-Endpoint für vorhandene Assets → muss 200 + `events[]` liefern; Korrektur-Endpoint → muss neue Deklaration mit `action_type='correction'` schreiben
6. **P-06 Audit-Log**: PATCH Sichtbarkeit eines Assets → `media_asset_audit_log` muss Eintrag `visibility_change` enthalten
7. **P-11**: Superadmin → Medium sperren → in Übung öffnen → Kachel zeigt „Gesperrt"; direkter Dateiaufruf `/exercises/{id}/media/{mid}/file` → muss HTTP 451 liefern; Plattform-Admin (kein Superadmin) → gesperrtes Medium darf in Medienliste nicht erscheinen
8. **P-13**: Anonym → `POST /api/content-reports` für ein official-Medium ohne Auth → muss 200 liefern; gültige Meldung ohne `good_faith_confirmed=true` → muss 400 liefern; Plattform-Admin → `GET /api/me/inbox/content-reports` → Liste; Superadmin → `POST /api/content-reports/{id}/legal-hold` → setzt Legal Hold + Report-Status `resolved_legal_hold`
Nächster vollständiger Re-Audit empfohlen nach juristischer Klärung P-06/KRIT-04 (Textfreigabe T1T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.

View File

@ -3,7 +3,7 @@
**Typ:** Kanonisches Referenzdokument
**Erstellt:** 2026-05-10
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.86)
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.87)
---
@ -266,11 +266,11 @@
|------|--------|
| **Kanonischer Titel** | Content-Melde-Backend (content_reports-Tabelle + Endpoints) |
| **Findings** | KRIT-03 |
| **Etappe** | 3 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Juristisch zu klären (DSA-Anwendungsbereich). Technische Spec vorhanden in `docs/compliance-audit.md` §17. |
| **Verweise** | `docs/compliance-audit.md` §12, §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
| **Etappe** | 3 → B (vorgezogen; → `docs/compliance-roadmap.md` §4) |
| **Status** | ✅ implemented |
| **Letzter Stand** | Vollständig umgesetzt (2026-05-11, Version 0.8.87). Migration 052 (`content_reports`-Tabelle, Indizes). `POST /api/content-reports`: optionale Auth via `X-Auth-Token`, official+aktive Medien ohne Login meldbar, good_faith_confirmed-Pflicht, automatische Priorisierung (high für minors/illegal_content/youth_protection). `GET /api/me/inbox/content-reports`: Plattform-Admin, JOIN auf Zieltabellen. `GET /api/content-reports/{id}`: Admin-Detail. `PATCH /api/content-reports/{id}`: Status/Notiz/Zuweisung (resolution_note für Abschluss-Status Pflicht). `POST /api/content-reports/{id}/legal-hold`: Superadmin, ruft P-11 `set_legal_hold()` auf (reason_code-Mapping), setzt Report-Status auf `resolved_legal_hold`. Inbox-Integration: Zweiter Abschnitt „Inhaltsmeldungen" in `InboxPage.jsx`; `OrgInboxContext` liefert `contentReports` + `contentReportCount`. Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. 15 Backend-Unit-Tests (`test_p13_content_reports.py`). |
| **Verweise** | `docs/compliance-audit.md` §12, §17; `backend/migrations/052_content_reports.sql`; `backend/routers/content_reports.py`; `backend/tests/test_p13_content_reports.py`; `frontend/src/pages/InboxPage.jsx`; `frontend/src/context/OrgInboxContext.jsx`; `frontend/src/utils/api.js` |
| **Hinweise** | Architekturentscheidung: Die bestehende Admin-Inbox (`InboxPage.jsx`) wurde um einen zweiten Abschnitt erweitert statt einer separaten Moderations-Queue. P-14 (Moderations-UI als eigenständige Seite), P-15 (Uploader-Benachrichtigung) und P-16 (Beschwerdeverfahren) folgen als eigenständige Pakete in Etappe D. E-Mail-Bestätigungen sind nicht in P-13-Scope (SMTP-Infrastruktur vorhanden, P-15 zugeordnet). |
---
@ -406,7 +406,7 @@
| P-10 | Mindestalter-Abfrage | 2 | HOCH-06 | ❌ open |
| P-11 | Legal-Hold Lifecycle-Status | 2 | MITT-02 | ✅ implemented |
| P-12 | sessionStorage bei Logout bereinigen | 2 | MITT-05 | ✅ implemented |
| P-13 | Content-Melde-Backend | 3 | KRIT-03 | ❌ open |
| P-13 | Content-Melde-Backend | 3→B | KRIT-03 | ✅ implemented |
| P-14 | Moderations-UI | 3 | KRIT-03 | ❌ open |
| P-15 | Uploader-Benachrichtigung bei Sperrung | 3 | KRIT-03 | ❌ open |
| P-16 | Beschwerdeverfahren | 3 | KRIT-03 | ❌ open |
@ -423,11 +423,11 @@
## Fortschritt
**Implementiert (vollständig):** P-03, P-03b, P-04, P-05, P-05b, P-07, P-11, P-12, P-23, P-24 — 10 Pakete (inkl. 2 Nacharbeiten)
**Implementiert (vollständig):** P-03, P-03b, P-04, P-05, P-05b, P-07, P-11, P-12, P-13, P-23, P-24 — 11 Pakete (inkl. 2 Nacharbeiten)
**Teilweise implementiert:** P-01 (technischer Teil vollständig inkl. P-01b, P-01c, copy-as-draft, jsPDF; juristische Inhalte offen) — 1 Paket
**Teilweise umgesetzt (KRIT offen):** P-06 (Upload-Einwilligungsdialog inkl. P-06+ Volljournal + Korrektur — KRIT-04 bis juristische Validierung ausstehend)
**Offen:** P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22 — 14 Pakete
**App-Version bei letzter Aktualisierung:** 0.8.86
**Offen:** P-02, P-08, P-09, P-10, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22 — 13 Pakete
**App-Version bei letzter Aktualisierung:** 0.8.87
**Letztes Umsetzungsdatum:** 2026-05-11
---

View File

@ -2,7 +2,7 @@
**Typ:** Lebendes Steuerungsdokument
**Erstellt:** 2026-05-10
**App-Version:** 0.8.86
**App-Version:** 0.8.87
**Zuletzt aktualisiert:** 2026-05-11
---
@ -29,7 +29,7 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
## 2. Aktueller Stand (2026-05-11)
### App-Version: 0.8.86
### App-Version: 0.8.87
### Teilweise umgesetzte Pakete
@ -55,13 +55,14 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
| P-01c | _Nacharbeit:_ Admin-konfigurierbare Rechtstexte (DB 047, Superadmin-UI, API) | 0.8.71 |
| P-01c+ | _Erweiterung:_ Als-Entwurf-kopieren (`copy-as-draft`) | 0.8.72 |
| P-01c++ | _Erweiterung:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 |
| P-13 | Content-Melde-Backend (Minimalversion, DSA/KRIT-03) | 0.8.87 |
**Vollständig abgeschlossen:** 9 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 15 Umsetzungseinheiten
**Vollständig abgeschlossen:** 10 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 16 Umsetzungseinheiten
**Teilweise umgesetzt (technisch):** P-01, P-06
### Offene Pakete (14)
### Offene Pakete (13)
P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
P-02, P-08, P-09, P-10, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
### Gesamtstatus
@ -72,7 +73,7 @@ P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-
1. **P-01:** Kein Impressum, keine Datenschutzerklärung, keine AGB. Eine öffentlich erreichbare App ohne Impressum ist eine Ordnungswidrigkeit nach § 5 DDG.
2. **P-06:** Keine Einwilligungserklärung beim Medienupload. Personenbilder können ohne jede Rechteabklärung hochgeladen werden.
3. **P-02:** Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Der Prozess ist noch nicht einmal fachlich spezifiziert.
4. **P-13:** Keine Möglichkeit, rechtswidrige Inhalte zu melden. Bei einer UGC-Plattform mit öffentlichen Inhalten ist dies ein erhebliches Restrisiko, unabhängig davon, ob der DSA formal anwendbar ist.
4. ~~**P-13:** Keine Möglichkeit, rechtswidrige Inhalte zu melden.~~**Implementiert (v0.8.87).** Content-Melde-Backend umgesetzt: POST `/api/content-reports`, Admin-Inbox-Integration, P-11 Legal-Hold-Anbindung.
5. ~~**P-11:** Kein Legal-Hold-Status.~~ ✅ **Implementiert (v0.8.84).**
**Was geöffnet bleiben kann (mit Dokumentation):** Vereinsinterne Nutzung durch bekannte Trainer-Gruppen ist mit dem aktuellen Stand vertretbar, solange keine öffentliche Vermarktung stattfindet und kein `official`-Content aktiviert wird.
@ -112,12 +113,10 @@ Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registri
**Finding:** MITT-02
**Abgeschlossen:** 2026-05-11. Migration 051, `media_legal_hold.py`, Retention-Schutz, Superadmin-API + Frontend (0.8.84); UI-Bugfixes (0.8.85); vollständige Auslieferungssperre (`download_exercise_media_file` HTTP 451), Frontend-Placeholder in allen Übungskomponenten, Medienliste nur noch für Superadmin mit Legal-Hold-Einträgen (0.8.86). Details: `docs/compliance-package-register.md` §P-11.
### Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)
### ~~Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)~~ ✅ Implementiert (v0.8.87)
**Finding:** KRIT-03
**Warum vorgezogen (Abweichung vom Initial-Audit):** → Siehe §4
**Minimalumfang:** Meldung einreichen + Moderations-Queue für Admin. Nicht der volle P-14P-16-Stack.
**Abhängigkeit:** ~~P-11 (Legal-Hold)~~ ✅ bereits implementiert.
**Abgeschlossen:** 2026-05-11. Migration 052 (`content_reports`); `POST /api/content-reports` (optionale Auth, official-Medien ohne Login meldbar); `GET /api/me/inbox/content-reports` (Plattform-Admin); `PATCH /api/content-reports/{id}`; `POST /api/content-reports/{id}/legal-hold` (Superadmin, P-11-Integration, reason_code-Mapping). Inbox-Erweiterung `InboxPage.jsx` mit zweitem Abschnitt „Inhaltsmeldungen". Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. 15 Backend-Unit-Tests. Details: `docs/compliance-package-register.md` §P-13.
### Blocker 5 — P-02: DSGVO-Self-Service (fachliche Spezifikation zuerst)
@ -176,16 +175,11 @@ Reihenfolge innerhalb der Etappe ist flexibel; P-01 und P-06 haben keine gegense
| P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 24 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
| P-06 | Upload-Einwilligungsdialog | 24 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
| P-13 | Content-Melde-Backend (Minimalversion) | 35 Tage | P-11 ✅ bereits erfüllt | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
| ~~P-13~~ | ~~Content-Melde-Backend (Minimalversion)~~ | — | ✅ implementiert (v0.8.87) | — |
**Hinweis P-01:** Die technische Anlage (leere Seiten, Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`) ist von der juristischen Ausarbeitung des Inhalts zu trennen. Beide Teilschritte können unabhängig voneinander freigegeben und durchgeführt werden.
**Hinweis P-13 Minimalumfang:** Im Rahmen von Etappe B umfasst P-13 ausschließlich:
- `POST /api/reports` — Meldung einreichen
- `GET /api/admin/reports` — Moderations-Queue
- `PATCH /api/admin/reports/{id}` — Status setzen
P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung), P-16 (Beschwerdeverfahren) folgen in Etappe D.
**Hinweis P-13:** ✅ Implementiert (v0.8.87). Endpunkte und Architektur: `POST /api/content-reports`, `GET /api/me/inbox/content-reports`, `PATCH /api/content-reports/{id}`, `POST /api/content-reports/{id}/legal-hold`. Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung), P-16 (Beschwerdeverfahren) folgen in Etappe D.
---

View File

@ -17,6 +17,10 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
function isPlatformAdmin(user) {
return user?.role === 'admin' || user?.role === 'superadmin'
}
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed'))
@ -24,44 +28,66 @@ export function notifyOrgInboxChanged() {
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => isPlatformAdmin(user), [user])
const refresh = useCallback(async () => {
if (!canAccess) {
setItems([])
return
} else {
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
}
}
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
if (!canAccessReports) {
setContentReports([])
} else {
try {
const data = await api.getInboxContentReports()
setContentReports(Array.isArray(data) ? data : [])
} catch {
setContentReports([])
}
}
}, [canAccess])
}, [canAccess, canAccessReports])
useEffect(() => {
if (!canAccess) {
if (!canAccess && !canAccessReports) {
setItems([])
setContentReports([])
return undefined
}
let cancelled = false
;(async () => {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
}
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
if (!cancelled) setContentReports(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setContentReports([])
}
}
})()
return () => {
cancelled = true
}
}, [canAccess, user?.id])
}, [canAccess, canAccessReports, user?.id])
useEffect(() => {
const onChange = () => {
refresh()
}
const onChange = () => { refresh() }
window.addEventListener('shinkan:inbox-changed', onChange)
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh])
@ -70,10 +96,14 @@ export function OrgInboxProvider({ user, children }) {
() => ({
inboxJoinRequests: items,
inboxCount: items.length,
contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
isSuperadmin: user?.role === 'superadmin',
}),
[items, refresh, canAccess]
[items, contentReports, refresh, canAccess, canAccessReports, user?.role]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -10,6 +10,33 @@ const CLUB_ROLE_OPTIONS = [
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const REASON_LABELS = {
copyright: 'Urheberrecht',
image_rights: 'Bildrechte',
privacy: 'Datenschutz / Persönlichkeitsrecht',
minors: 'Minderjährige',
illegal_content: 'Rechtswidriger Inhalt',
youth_protection: 'Jugendschutz',
offensive_content: 'Beleidigender Inhalt',
other: 'Sonstiges',
}
const STATUS_LABELS = {
submitted: 'Eingegangen',
under_review: 'In Bearbeitung',
resolved_no_action: 'Abgeschlossen (kein Handlungsbedarf)',
resolved_legal_hold: 'Abgeschlossen (Legal Hold)',
rejected_invalid: 'Abgewiesen (ungültig)',
}
const STATUS_COLORS = {
submitted: 'var(--accent)',
under_review: '#e8960a',
resolved_no_action: 'var(--text3)',
resolved_legal_hold: 'var(--danger)',
rejected_invalid: 'var(--text3)',
}
function formatWhen(iso) {
if (!iso) return ''
const s = String(iso)
@ -19,13 +46,292 @@ function formatWhen(iso) {
return time ? `${d} · ${time}` : d
}
function PriorityBadge({ priority }) {
if (priority !== 'high') return null
return (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '4px',
padding: '2px 7px',
fontSize: '0.72rem',
fontWeight: 600,
marginLeft: '0.4rem',
verticalAlign: 'middle',
}}
>
DRINGEND
</span>
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
const [resolutionNote, setResolutionNote] = useState('')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
const isOpen = report.status === 'submitted' || report.status === 'under_review'
async function handleStatus(status) {
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
setError('Bitte eine Begründung eingeben.')
return
}
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, {
status,
resolution_note: resolutionNote.trim() || undefined,
})
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleUnderReview() {
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, { status: 'under_review' })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleLegalHold() {
if (!legalHoldNote.trim()) {
setError('Begründung für Legal Hold erforderlich.')
return
}
setSaving(true)
setError(null)
try {
await api.setLegalHoldFromReport(report.id, { reason_note: legalHoldNote.trim() })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '560px',
width: '100%',
marginTop: '2rem',
marginBottom: '2rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>
Meldung #{report.id}
<PriorityBadge priority={report.priority} />
</h2>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', marginBottom: '1.2rem' }}>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
{STATUS_LABELS[report.status] || report.status}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Ziel</span>
<div>
{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id}
{report.target_name ? ` ${report.target_name}` : ''}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</span>
<div>
{report.reporter_name}
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
<div>{formatWhen(report.submitted_at || report.created_at)}</div>
</div>
{report.resolution_note && (
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</div>
</div>
)}
{report.legal_hold_active && (
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.6rem 0.8rem' }}>
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>Legal Hold aktiv</span>
</div>
)}
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
)}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={handleUnderReview} disabled={saving}>
In Bearbeitung nehmen
</button>
)}
<div className="form-row">
<label className="form-label">Begründung (erforderlich für Abschluss/Abweisung)</label>
<textarea
className="form-input"
rows={2}
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Kurze Begründung der Entscheidung…"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleStatus('resolved_no_action')}
disabled={saving}
>
Kein Handlungsbedarf
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleStatus('rejected_invalid')}
disabled={saving}
>
Meldung abweisen
</button>
</div>
{isSuperadmin && !showLegalHoldForm && (
<button
type="button"
className="btn"
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
onClick={() => setShowLegalHoldForm(true)}
>
Legal Hold setzen (Superadmin)
</button>
)}
{isSuperadmin && showLegalHoldForm && (
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
<div className="form-row">
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
<textarea
className="form-input"
rows={2}
value={legalHoldNote}
onChange={(e) => setLegalHoldNote(e.target.value)}
placeholder="Rechtliche Begründung für den Legal Hold…"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', flex: 1 }}
onClick={handleLegalHold}
disabled={saving}
>
Legal Hold bestätigen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>
Abbrechen
</button>
</div>
</div>
)}
</div>
)}
{!isOpen && (
<p className="muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Diese Meldung ist bereits abgeschlossen.
</p>
)}
</div>
</div>
)
}
export default function InboxPage() {
const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox()
const {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
contentReportCount,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
const load = useCallback(async () => {
if (!canAccessOrgInbox) {
if (!canAccessOrgInbox && !canAccessContentReports) {
setLoading(false)
return
}
@ -35,13 +341,13 @@ export default function InboxPage() {
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, refreshOrgInbox])
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox) {
if (!canAccessOrgInbox && !canAccessContentReports) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
@ -61,7 +367,7 @@ export default function InboxPage() {
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Offene Beitrittsanträge zu Vereinen, für die du zuständig bist.
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
@ -73,68 +379,168 @@ export default function InboxPage() {
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
) : inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">
Keine offenen Beitrittsanträge.
</p>
</div>
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
<>
{/* Abschnitt 1: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Beitrittsanträge
{inboxJoinRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxJoinRequests.length}
</span>
)}
</h2>
{inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Beitrittsanträge.</p>
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(req.club_id, req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(req.club_id, req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */}
{canAccessContentReports && (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{contentReportCount} neu
</span>
)}
</h2>
{contentReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{contentReports.map((rep) => (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
}}
onClick={() => setReportModal(rep)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>
Meldung #{rep.id}
<PriorityBadge priority={rep.priority} />
</span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
{rep.target_name ? ` ${rep.target_name}` : ''}
</span>
</div>
<span
style={{
fontSize: '0.78rem',
fontWeight: 500,
color: STATUS_COLORS[rep.status],
whiteSpace: 'nowrap',
}}
>
{STATUS_LABELS[rep.status] || rep.status}
</span>
</div>
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
{REASON_LABELS[rep.report_reason] || rep.report_reason}
{' · '}
{rep.reporter_name}
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
{' · '}
{formatWhen(rep.submitted_at || rep.created_at)}
</div>
</div>
))}
</div>
)}
</section>
)}
</>
)}
{acceptModal && (
@ -217,6 +623,15 @@ export default function InboxPage() {
</div>
</div>
)}
{reportModal && (
<ReportDetailModal
report={reportModal}
isSuperadmin={isSuperadmin}
onClose={() => setReportModal(null)}
onRefresh={load}
/>
)}
</div>
)
}

View File

@ -1592,6 +1592,18 @@ export const api = {
request(`/api/admin/legal-documents/${id}/copy-as-draft`, { method: 'POST' }),
getLegalDocumentAudit: (id) =>
request(`/api/admin/legal-documents/${id}/audit`),
// P-13: Content-Melde-Backend
submitContentReport: (body) =>
request('/api/content-reports', { method: 'POST', body: JSON.stringify(body) }),
getInboxContentReports: () =>
request('/api/me/inbox/content-reports'),
getContentReport: (id) =>
request(`/api/content-reports/${id}`),
patchContentReport: (id, body) =>
request(`/api/content-reports/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
setLegalHoldFromReport: (id, body) =>
request(`/api/content-reports/${id}/legal-hold`, { method: 'POST', body: JSON.stringify(body) }),
}
export default api

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.86"
export const APP_VERSION = "0.8.87"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
@ -25,4 +25,5 @@ export const PAGE_VERSIONS = {
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
InboxPage: "2.0.0", // P-13: Inhaltsmeldungen-Abschnitt integriert
}