diff --git a/backend/routers/content_reports.py b/backend/routers/content_reports.py index 3bb3238..55733c7 100644 --- a/backend/routers/content_reports.py +++ b/backend/routers/content_reports.py @@ -152,9 +152,60 @@ class ContentReportLegalHoldBody(BaseModel): # ─── 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 _assert_can_manage_report(cur, role: Optional[str], pid: int, report: dict) -> None: + """Platform-Admins: jede Meldung. Club-Admin: nur Meldungen zu Medien ihres Vereins.""" + if is_platform_admin(role): + return + if report.get("target_type") != "media_asset": + raise HTTPException(status_code=403, detail="Nur Plattform-Admins können Meldungen zu Übungen bearbeiten") + cur.execute("SELECT club_id FROM media_assets WHERE id = %s", (int(report["target_id"]),)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + club_id = r2d(row).get("club_id") + if not club_id: + raise HTTPException(status_code=403, detail="Kein Zugriff – Medium ohne Vereinszugehörigkeit") + 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' + """, + (pid, club_id), + ) + if cur.fetchone() is None: + raise HTTPException(status_code=403, detail="Kein Zugriff auf diese Meldung") + + +def _assert_can_set_legal_hold_from_report(cur, role: Optional[str], pid: int, asset_id: int) -> None: + """Superadmin: immer. Club-Admin: nur für Vereinsmedien mit visibility != 'official'.""" + if is_superadmin(role): + return + cur.execute("SELECT club_id, visibility FROM media_assets WHERE id = %s", (asset_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + asset = r2d(row) + if asset.get("visibility") == "official": + raise HTTPException( + status_code=403, + detail="Legal Hold auf offiziellen Medien erfordert Superadmin-Rechte", + ) + club_id = asset.get("club_id") + if not club_id: + raise HTTPException(status_code=403, detail="Legal Hold erfordert Superadmin-Rechte für Medien ohne Vereinszugehörigkeit") + 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' + """, + (pid, club_id), + ) + if cur.fetchone() is None: + raise HTTPException(status_code=403, detail="Kein Zugriff – Superadmin oder Vereinsadmin des Vereins erforderlich") def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool: @@ -651,8 +702,9 @@ 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) + """Detail-Ansicht einer Meldung fuer Plattform-Admins und zustaendige Club-Admins.""" + pid = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -683,7 +735,9 @@ def get_content_report( row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") - return _report_row_to_dict(row) + report_dict = _report_row_to_dict(row) + _assert_can_manage_report(cur, role, pid, report_dict) + return report_dict @router.patch("/content-reports/{report_id}") @@ -695,9 +749,10 @@ def patch_content_report( """ Status und Bearbeitungsnotiz einer Meldung aktualisieren. Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note. + Plattform-Admins: jede Meldung. Club-Admins: nur Meldungen zu Medien ihres Vereins. """ - _assert_platform_admin(tenant.global_role) pid = tenant.profile_id + role = tenant.global_role if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip(): raise HTTPException( @@ -723,6 +778,7 @@ def patch_content_report( if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") old_report = r2d(row) + _assert_can_manage_report(cur, role, pid, old_report) old_status = old_report["status"] old_note = (old_report.get("resolution_note") or "").strip() @@ -802,13 +858,14 @@ def set_legal_hold_from_report( ): """ Legal Hold (P-11) aus einer Meldung heraus setzen. - Nur Superadmin. Nur fuer Meldungen mit target_type='media_asset'. + Superadmin: immer. Club-Admin: nur fuer Vereinsmedien (visibility != 'official'). + 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 + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -828,6 +885,7 @@ def set_legal_hold_from_report( ) asset_id = int(report["target_id"]) + _assert_can_set_legal_hold_from_report(cur, role, pid, asset_id) reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other") # P-11-Service aufrufen diff --git a/backend/version.py b/backend/version.py index 720f0cc..126bac1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.92" +APP_VERSION = "0.8.93" BUILD_DATE = "2026-05-11" DB_SCHEMA_VERSION = "20260511053" @@ -30,10 +30,20 @@ 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.4.0", # P-13: Workflow-Reset (wieder öffnen), Kommentar-Audit-Log, PATCH-Verbesserungen + "content_reports": "1.5.0", # P-13: Club-Admin Bearbeitung + Legal Hold (Vereinsebene), Archiv-Trennung } CHANGELOG = [ + { + "version": "0.8.93", + "date": "2026-05-11", + "changes": [ + "Fix P-13: Club-Admins können Inhaltsmeldungen zu Vereinsmedien bearbeiten (PATCH, GET-Detail).", + "Fix P-13: Club-Admins können Legal Hold auf Vereinsmedien (nicht 'official') aus Meldung heraus setzen.", + "Fix P-13: Abgeschlossene Meldungen in der Inbox in kollabierbare Archiv-Sektion verschoben.", + "Fix P-13: isClubAdmin + isPlatformAdmin im OrgInboxContext exponiert.", + ], + }, { "version": "0.8.92", "date": "2026-05-11", diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx index 84e994a..f76f01c 100644 --- a/frontend/src/context/OrgInboxContext.jsx +++ b/frontend/src/context/OrgInboxContext.jsx @@ -115,8 +115,10 @@ export function OrgInboxProvider({ user, children }) { canAccessOrgInbox: canAccess, canAccessContentReports: canAccessReports, isSuperadmin: user?.role === 'superadmin', + isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin', + isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')), }), - [items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role] + [items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs] ) return {children} diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx index 8c2492c..11f123e 100644 --- a/frontend/src/pages/InboxPage.jsx +++ b/frontend/src/pages/InboxPage.jsx @@ -107,7 +107,7 @@ function WorkflowBar({ status }) { ) } -function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) { +function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin, isClubAdmin }) { const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '') const [legalHoldNote, setLegalHoldNote] = useState('') const [saving, setSaving] = useState(false) @@ -279,13 +279,13 @@ function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) { Meldung abweisen - {isSuperadmin && !showLegalHoldForm && ( + {(isSuperadmin || (isClubAdmin && report.target_visibility !== 'official')) && !showLegalHoldForm && ( )} {isSuperadmin && showLegalHoldForm && ( @@ -325,6 +325,8 @@ export default function InboxPage() { canAccessOrgInbox, canAccessContentReports, isSuperadmin, + isPlatformAdmin, + isClubAdmin, refreshOrgInbox, inboxJoinRequests, contentReports, @@ -334,6 +336,7 @@ export default function InboxPage() { const [loading, setLoading] = useState(true) const [acceptModal, setAcceptModal] = useState(null) const [reportModal, setReportModal] = useState(null) + const [showArchive, setShowArchive] = useState(false) const load = useCallback(async () => { if (!canAccessOrgInbox && !canAccessContentReports) { @@ -471,89 +474,108 @@ export default function InboxPage() { )} - {/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */} - {canAccessContentReports && ( -
-

- Inhaltsmeldungen - {contentReportCount > 0 && ( - - {contentReportCount} neu - - )} -

+ {/* Abschnitt 2: Inhaltsmeldungen */} + {canAccessContentReports && (() => { + const openReports = contentReports.filter((r) => r.status === 'submitted' || r.status === 'under_review') + const archivedReports = contentReports.filter((r) => r.status !== 'submitted' && r.status !== 'under_review') - {contentReportsError ? ( -
-

- Fehler beim Laden: {contentReportsError} -

- -
- ) : contentReports.length === 0 ? ( -
-

Keine Inhaltsmeldungen.

-
- ) : ( -
- {contentReports.map((rep) => ( -
setReportModal(rep)} - > -
-
- - Meldung #{rep.id} - - - - {rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id} - {rep.target_filename || rep.target_exercise_name ? ` – ${rep.target_filename || rep.target_exercise_name}` : ''} - -
- - {STATUS_LABELS[rep.status] || rep.status} - -
-
- {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)} -
+ function ReportCard({ rep }) { + return ( +
setReportModal(rep)} + > +
+
+ + Meldung #{rep.id} + + + + {rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id} + {rep.target_filename || rep.target_exercise_name ? ` – ${rep.target_filename || rep.target_exercise_name}` : ''} +
- ))} + + {STATUS_LABELS[rep.status] || rep.status} + +
+
+ {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)} +
- )} -
- )} + ) + } + + return ( +
+

+ Inhaltsmeldungen + {contentReportCount > 0 && ( + + {contentReportCount} neu + + )} +

+ + {contentReportsError ? ( +
+

+ Fehler beim Laden: {contentReportsError} +

+ +
+ ) : ( + <> + {openReports.length === 0 ? ( +
+

Keine offenen Inhaltsmeldungen.

+
+ ) : ( +
+ {openReports.map((rep) => )} +
+ )} + + {archivedReports.length > 0 && ( +
+ + {showArchive && ( +
+ {archivedReports.map((rep) => )} +
+ )} +
+ )} + + )} +
+ ) + })()} )} @@ -642,6 +664,7 @@ export default function InboxPage() { setReportModal(null)} onRefresh={load} /> diff --git a/frontend/src/version.js b/frontend/src/version.js index 0215d12..cb40418 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.92" +export const APP_VERSION = "0.8.93" export const BUILD_DATE = "2026-05-11" export const PAGE_VERSIONS = { @@ -27,8 +27,8 @@ export const PAGE_VERSIONS = { ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer - InboxPage: "2.2.0", // P-13: Workflow-Balken, Wieder-öffnen, Kommentar, Fehleranzeigezeige - OrgInboxContext: "1.2.0", // P-13: contentReportsError exposed + InboxPage: "2.3.0", // P-13: Archiv-Trennung offen/abgeschlossen; Club-Admin Legal-Hold-Button + OrgInboxContext: "1.3.0", // P-13: isClubAdmin + isPlatformAdmin exposed MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional) ReportContentModal: "1.2.0", // P-13: onSuccess callback fuer Badge-Update }