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
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:
parent
3c0e63757c
commit
60709df615
|
|
@ -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>).
|
||||
|
|
|
|||
73
backend/migrations/052_content_reports.sql
Normal file
73
backend/migrations/052_content_reports.sql
Normal 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);
|
||||
584
backend/routers/content_reports.py
Normal file
584
backend/routers/content_reports.py
Normal 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,
|
||||
}
|
||||
327
backend/tests/test_p13_content_reports.py
Normal file
327
backend/tests/test_p13_content_reports.py
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.84–0.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 T1–T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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-14–P-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) | 2–4 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
|
||||
| P-06 | Upload-Einwilligungsdialog | 2–4 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
|
||||
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
|
||||
| P-13 | Content-Melde-Backend (Minimalversion) | 3–5 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user