From f8294f1b0aef0145a1b0b3e8f09e09547681f602 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 8 Jun 2026 16:31:15 +0200 Subject: [PATCH] Add release report response --- .../Admin/AdminReleaseReportController.php | 46 ++++++++ app/Http/Controllers/DetailsController.php | 13 ++- .../Controllers/ReleaseReportController.php | 2 + app/Models/ReleaseReport.php | 18 ++- .../Releases/ReleaseBrowseService.php | 6 +- .../Releases/ReleaseSearchService.php | 4 +- ...sponse_fields_to_release_reports_table.php | 37 ++++++ .../js/alpine/components/release-report.js | 22 ++++ .../admin/release-reports/index.blade.php | 108 +++++++++++++++++- resources/views/browse/index.blade.php | 20 ++++ resources/views/details/index.blade.php | 20 ++++ routes/web.php | 1 + .../AdminReleaseReportControllerTest.php | 101 +++++++++++++++- 13 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 database/migrations/2026_06_08_000000_add_response_fields_to_release_reports_table.php diff --git a/app/Http/Controllers/Admin/AdminReleaseReportController.php b/app/Http/Controllers/Admin/AdminReleaseReportController.php index 4f132ff6c..ff6856261 100644 --- a/app/Http/Controllers/Admin/AdminReleaseReportController.php +++ b/app/Http/Controllers/Admin/AdminReleaseReportController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\Release; use App\Models\ReleaseReport; +use App\Services\Releases\ReleaseBrowseService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -51,10 +52,48 @@ class AdminReleaseReportController extends BasePageController 'reviewed_by' => Auth::id(), 'reviewed_at' => now(), ]); + ReleaseBrowseService::bumpCacheVersion(); return redirect()->back()->with('success', 'Report status updated successfully.'); } + /** + * Update the staff response for a report. + */ + public function updateResponse(Request $request, int $id): RedirectResponse + { + $request->validate([ + 'response' => 'nullable|string|max:2000', + 'response_is_public' => 'nullable|boolean', + ]); + + $report = ReleaseReport::findOrFail($id); + $response = trim((string) $request->input('response', '')); + + if ($response === '') { + $report->update([ + 'response' => null, + 'responded_by' => null, + 'responded_at' => null, + 'response_is_public' => true, + ]); + + ReleaseBrowseService::bumpCacheVersion(); + + return redirect()->back()->with('success', 'Report response cleared successfully.'); + } + + $report->update([ + 'response' => $response, + 'responded_by' => Auth::id(), + 'responded_at' => now(), + 'response_is_public' => $request->boolean('response_is_public'), + ]); + ReleaseBrowseService::bumpCacheVersion(); + + return redirect()->back()->with('success', 'Report response saved successfully.'); + } + /** * Delete the reported release and resolve the report. */ @@ -78,6 +117,7 @@ class AdminReleaseReportController extends BasePageController 'reviewed_by' => Auth::id(), 'reviewed_at' => now(), ]); + ReleaseBrowseService::bumpCacheVersion(); return redirect()->back()->with('success', "Release '{$releaseName}' deleted and all related reports resolved."); } @@ -96,6 +136,7 @@ class AdminReleaseReportController extends BasePageController 'reviewed_by' => Auth::id(), 'reviewed_at' => now(), ]); + ReleaseBrowseService::bumpCacheVersion(); return redirect()->back()->with('success', 'Report dismissed successfully.'); } @@ -116,6 +157,7 @@ class AdminReleaseReportController extends BasePageController 'reviewed_by' => Auth::id(), 'reviewed_at' => now(), ]); + ReleaseBrowseService::bumpCacheVersion(); return redirect()->back()->with('success', 'Report reverted to reviewed status successfully.'); } @@ -180,6 +222,10 @@ class AdminReleaseReportController extends BasePageController $count++; } + if ($count > 0) { + ReleaseBrowseService::bumpCacheVersion(); + } + $actionLabel = match ($action) { 'delete' => 'deleted', 'dismiss' => 'dismissed', diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 1d827040c..3a7f3271f 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -72,11 +72,21 @@ class DetailsController extends BasePageController $comments = ReleaseComment::getComments($data['id']); $similars = $this->releaseSearchService->searchSimilar($data['id'], $data['searchname'], (array) $this->userdata->categoryexclusions); $failed = DnzbFailure::getFailedCount($data['id']); - $reportData = ReleaseReport::where('releases_id', $data['id']) + $reportData = ReleaseReport::query() + ->with('responder') + ->where('releases_id', $data['id']) ->whereIn('status', ['pending', 'reviewed', 'resolved']) ->get(); $reportCount = $reportData->count(); $reportReasons = ReleaseReport::reasonKeysToLabels($reportData->pluck('reason')->unique()->implode(', ')); + $publicReportResponses = ReleaseReport::query() + ->with('responder') + ->where('releases_id', $data['id']) + ->where('response_is_public', true) + ->whereNotNull('response') + ->where('response', '!=', '') + ->orderByDesc('responded_at') + ->get(); $downloadedBy = UserDownload::query()->with('user')->where('releases_id', $data['id'])->get(['users_id']); $showInfo = ''; @@ -175,6 +185,7 @@ class DetailsController extends BasePageController 'failed' => $failed, 'reportCount' => $reportCount, 'reportReasons' => $reportReasons, + 'publicReportResponses' => $publicReportResponses, 'regex' => $releaseRegex, 'downloadedby' => $downloadedBy, 'meta_title' => 'View NZB', diff --git a/app/Http/Controllers/ReleaseReportController.php b/app/Http/Controllers/ReleaseReportController.php index a9326ff7a..3cd570cf8 100644 --- a/app/Http/Controllers/ReleaseReportController.php +++ b/app/Http/Controllers/ReleaseReportController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Models\Release; use App\Models\ReleaseReport; +use App\Services\Releases\ReleaseBrowseService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -51,6 +52,7 @@ class ReleaseReportController extends BasePageController 'description' => $validated['description'] ?? null, 'status' => 'pending', ]); + ReleaseBrowseService::bumpCacheVersion(); return response()->json([ 'success' => true, diff --git a/app/Models/ReleaseReport.php b/app/Models/ReleaseReport.php index 677e11380..d1868841c 100644 --- a/app/Models/ReleaseReport.php +++ b/app/Models/ReleaseReport.php @@ -26,9 +26,13 @@ class ReleaseReport extends Model 'users_id', 'reason', 'description', + 'response', 'status', 'reviewed_by', 'reviewed_at', + 'responded_by', + 'responded_at', + 'response_is_public', ]; /** @@ -36,6 +40,8 @@ class ReleaseReport extends Model */ protected $casts = [ 'reviewed_at' => 'datetime', + 'responded_at' => 'datetime', + 'response_is_public' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; @@ -102,6 +108,16 @@ class ReleaseReport extends Model return $this->belongsTo(User::class, 'reviewed_by'); } + /** + * Get the admin who responded to the report. + * + * @return BelongsTo + */ + public function responder(): BelongsTo + { + return $this->belongsTo(User::class, 'responded_by'); + } + /** * Get a paginated list of reports with optional filters. */ @@ -110,7 +126,7 @@ class ReleaseReport extends Model int $perPage = 50 ): LengthAwarePaginator { $query = self::query() - ->with(['release', 'user', 'reviewer']) + ->with(['release', 'user', 'reviewer', 'responder']) ->orderBy('created_at', 'desc'); if ($status !== null && $status !== 'all') { diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php index 50769212b..3dbfedc64 100644 --- a/app/Services/Releases/ReleaseBrowseService.php +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -167,6 +167,8 @@ class ReleaseBrowseService df.failed AS failed_count, rr.report_count AS report_count, rr.report_reasons AS report_reasons, + rr.response_count AS report_response_count, + rr.latest_response_at AS latest_report_response_at, rn.releases_id AS nfoid, re.releases_id AS reid, m.imdbid"; @@ -175,8 +177,8 @@ class ReleaseBrowseService LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id LEFT OUTER JOIN video_data re ON re.releases_id = r.id LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id - LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id - LEFT OUTER JOIN (SELECT releases_id, COUNT(*) AS report_count, GROUP_CONCAT(DISTINCT reason SEPARATOR ', ') AS report_reasons FROM release_reports WHERE status IN ('pending', 'reviewed', 'resolved') GROUP BY releases_id) rr ON rr.releases_id = r.id"; + LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id + LEFT OUTER JOIN (SELECT releases_id, SUM(CASE WHEN status IN ('pending', 'reviewed', 'resolved') THEN 1 ELSE 0 END) AS report_count, GROUP_CONCAT(DISTINCT CASE WHEN status IN ('pending', 'reviewed', 'resolved') THEN reason ELSE NULL END SEPARATOR ', ') AS report_reasons, SUM(CASE WHEN response IS NOT NULL AND response != '' AND response_is_public = 1 THEN 1 ELSE 0 END) AS response_count, MAX(CASE WHEN response IS NOT NULL AND response != '' AND response_is_public = 1 THEN responded_at ELSE NULL END) AS latest_response_at FROM release_reports GROUP BY releases_id) rr ON rr.releases_id = r.id"; $innerSelect = 'SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.haspreview, r.jpgstatus, r.nfostatus, g.name AS group_name, r.movieinfo_id'; } diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index 90328ab3f..c7150efb0 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -1903,6 +1903,8 @@ class ReleaseSearchService df.failed AS failed_count, rr.report_count AS report_count, rr.report_reasons AS report_reasons, + rr.response_count AS report_response_count, + rr.latest_response_at AS latest_report_response_at, g.name AS group_name, rn.releases_id AS nfoid, re.releases_id AS reid, @@ -1915,7 +1917,7 @@ class ReleaseSearchService LEFT JOIN categories c ON c.id = r.categories_id LEFT JOIN root_categories cp ON cp.id = c.root_categories_id LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id - LEFT OUTER JOIN (SELECT releases_id, COUNT(*) AS report_count, GROUP_CONCAT(DISTINCT reason SEPARATOR ', ') AS report_reasons FROM release_reports WHERE status IN ('pending', 'reviewed', 'resolved') GROUP BY releases_id) rr ON rr.releases_id = r.id + LEFT OUTER JOIN (SELECT releases_id, SUM(CASE WHEN status IN ('pending', 'reviewed', 'resolved') THEN 1 ELSE 0 END) AS report_count, GROUP_CONCAT(DISTINCT CASE WHEN status IN ('pending', 'reviewed', 'resolved') THEN reason ELSE NULL END SEPARATOR ', ') AS report_reasons, SUM(CASE WHEN response IS NOT NULL AND response != '' AND response_is_public = 1 THEN 1 ELSE 0 END) AS response_count, MAX(CASE WHEN response IS NOT NULL AND response != '' AND response_is_public = 1 THEN responded_at ELSE NULL END) AS latest_response_at FROM release_reports GROUP BY releases_id) rr ON rr.releases_id = r.id %s", $whereSql ); diff --git a/database/migrations/2026_06_08_000000_add_response_fields_to_release_reports_table.php b/database/migrations/2026_06_08_000000_add_response_fields_to_release_reports_table.php new file mode 100644 index 000000000..52c1754e5 --- /dev/null +++ b/database/migrations/2026_06_08_000000_add_response_fields_to_release_reports_table.php @@ -0,0 +1,37 @@ +text('response')->nullable()->after('description'); + $table->unsignedInteger('responded_by')->nullable()->after('reviewed_at'); + $table->timestamp('responded_at')->nullable()->after('responded_by'); + $table->boolean('response_is_public')->default(true)->after('responded_at'); + + $table->foreign('responded_by')->references('id')->on('users')->nullOnDelete(); + $table->index(['releases_id', 'response_is_public', 'responded_at'], 'release_reports_response_lookup_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('release_reports', function (Blueprint $table) { + $table->dropIndex('release_reports_response_lookup_idx'); + $table->dropForeign(['responded_by']); + $table->dropColumn(['response', 'responded_by', 'responded_at', 'response_is_public']); + }); + } +}; + diff --git a/resources/js/alpine/components/release-report.js b/resources/js/alpine/components/release-report.js index 13c36713b..e6dbbe85a 100644 --- a/resources/js/alpine/components/release-report.js +++ b/resources/js/alpine/components/release-report.js @@ -113,6 +113,10 @@ Alpine.data('adminReleaseReports', () => ({ revertModalOpen: false, revertActionUrl: '', revertStatus: '', + responseModalOpen: false, + responseActionUrl: '', + responseContent: '', + responseIsPublic: true, allChecked: false, selectedCount: 0, rootEl: null, @@ -144,6 +148,24 @@ Alpine.data('adminReleaseReports', () => ({ if (form) { form.action = this.revertActionUrl; form.submit(); } }, + showResponse(actionUrl, response, isPublic) { + this.responseActionUrl = actionUrl; + this.responseContent = response || ''; + this.responseIsPublic = isPublic !== false; + this.responseModalOpen = true; + }, + + closeResponse() { this.responseModalOpen = false; }, + + responseCharCount() { + return (this.responseContent || '').length; + }, + + submitResponse() { + const form = this.$refs.responseForm; + if (form) { form.action = this.responseActionUrl; form.submit(); } + }, + componentRoot() { return this.rootEl || this.$root; }, diff --git a/resources/views/admin/release-reports/index.blade.php b/resources/views/admin/release-reports/index.blade.php index 9042dc85a..e5a8bdb80 100644 --- a/resources/views/admin/release-reports/index.blade.php +++ b/resources/views/admin/release-reports/index.blade.php @@ -111,6 +111,7 @@ Release Reporter Reason + Response Status Date Actions @@ -176,6 +177,24 @@ @endif + + @if($report->response) +
+ + + {{ $report->response_is_public ? 'Public response' : 'Private note' }} + +

{{ Str::limit($report->response, 90) }}

+ @if($report->responder && $report->responded_at) +
+ by {{ $report->responder->username }} at {{ $report->responded_at->format('M d, Y H:i') }} +
+ @endif +
+ @else + No response + @endif + @php $statusColors = [ @@ -280,11 +299,27 @@ title="Revert to Reviewed for further action"> Revert - {{ ucfirst($report->status) }} + + + {{ ucfirst($report->status) }} + @else - {{ ucfirst($report->status) }} + + + {{ ucfirst($report->status) }} + @endif + + @if($report->release) + +
+ +
+ + +
+
+ +
+
+

+ Staff Response +

+ +
+ +
+ @csrf +
+
+ + +
+ Leave blank and save to clear the response. + /2000 +
+
+ + +
+ +
+ + +
+
+
+
+
+
+
Reported ({{ $result->report_count }}) @endif + @if(!empty($result->report_response_count) && $result->report_response_count > 0) + + Response + + @endif @if(!empty($result->failed_count) && $result->failed_count > 0) @@ -283,6 +289,20 @@ {{ $result->searchname }} +
+ @if(!empty($result->report_count) && $result->report_count > 0) + + Reported ({{ $result->report_count }}) + + @endif + @if(!empty($result->report_response_count) && $result->report_response_count > 0) + + Response + + @endif +
{{ $result->category_name ?? 'Other' }} diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index a23f69bc2..a9584d5d6 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -70,6 +70,26 @@ @endauth
+ @if(!empty($publicReportResponses) && $publicReportResponses->count() > 0) +
+

+ Staff response +

+
+ @foreach($publicReportResponses as $responseReport) +
+
{{ $responseReport->response }}
+
+ {{ $responseReport->responded_at ? 'Responded ' . $responseReport->responded_at->format('M d, Y H:i') : 'Staff response' }} + @if($responseReport->responder) + by {{ $responseReport->responder->username }} + @endif +
+
+ @endforeach +
+
+ @endif
diff --git a/routes/web.php b/routes/web.php index ed7a63e8f..e52b747c1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -302,6 +302,7 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { // Release Reports Management Route::get('release-reports', [AdminReleaseReportController::class, 'index'])->name('admin.release-reports'); Route::post('release-reports/{id}/status', [AdminReleaseReportController::class, 'updateStatus'])->name('admin.release-reports.update-status'); + Route::post('release-reports/{id}/response', [AdminReleaseReportController::class, 'updateResponse'])->name('admin.release-reports.update-response'); Route::post('release-reports/{id}/delete-release', [AdminReleaseReportController::class, 'deleteRelease'])->name('admin.release-reports.delete-release'); Route::post('release-reports/{id}/dismiss', [AdminReleaseReportController::class, 'dismiss'])->name('admin.release-reports.dismiss'); Route::post('release-reports/{id}/revert', [AdminReleaseReportController::class, 'revert'])->name('admin.release-reports.revert'); diff --git a/tests/Feature/AdminReleaseReportControllerTest.php b/tests/Feature/AdminReleaseReportControllerTest.php index dc8b84241..5f2ff2d3c 100644 --- a/tests/Feature/AdminReleaseReportControllerTest.php +++ b/tests/Feature/AdminReleaseReportControllerTest.php @@ -189,8 +189,8 @@ class AdminReleaseReportControllerTest extends TestCase $content = file_get_contents($servicePath); - // Check that the query includes resolved status - $this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content); + // Check that the active report count includes resolved status. + $this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content); } /** @@ -204,8 +204,8 @@ class AdminReleaseReportControllerTest extends TestCase $content = file_get_contents($servicePath); - // Check that the query includes resolved status - $this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content); + // Check that the active report count includes resolved status. + $this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content); } /** @@ -219,8 +219,8 @@ class AdminReleaseReportControllerTest extends TestCase $content = file_get_contents($servicePath); - // Check that the query includes resolved status - $this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content); + // Check that the active report count includes resolved status. + $this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content); } /** @@ -237,4 +237,93 @@ class AdminReleaseReportControllerTest extends TestCase // Check that the query includes resolved status $this->assertStringContainsString("whereIn('status', ['pending', 'reviewed', 'resolved'])", $content); } + + /** + * Test that release reports support staff response fields. + */ + public function test_release_report_model_supports_response_fields(): void + { + $modelPath = app_path('Models/ReleaseReport.php'); + + $this->assertFileExists($modelPath); + + $content = file_get_contents($modelPath); + + $this->assertStringContainsString("'response'", $content); + $this->assertStringContainsString("'responded_by'", $content); + $this->assertStringContainsString("'responded_at'", $content); + $this->assertStringContainsString("'response_is_public'", $content); + $this->assertStringContainsString('public function responder', $content); + } + + /** + * Test that the response route and controller method are defined. + */ + public function test_response_route_and_controller_method_are_defined(): void + { + $routesPath = base_path('routes/web.php'); + $controllerPath = app_path('Http/Controllers/Admin/AdminReleaseReportController.php'); + + $this->assertFileExists($routesPath); + $this->assertFileExists($controllerPath); + + $routes = file_get_contents($routesPath); + $controller = file_get_contents($controllerPath); + + $this->assertStringContainsString('release-reports/{id}/response', $routes); + $this->assertStringContainsString('admin.release-reports.update-response', $routes); + $this->assertStringContainsString('public function updateResponse', $controller); + $this->assertStringContainsString("'response' => 'nullable|string|max:2000'", $controller); + $this->assertStringContainsString('ReleaseBrowseService::bumpCacheVersion();', $controller); + } + + /** + * Test that the admin release reports view includes response UI. + */ + public function test_admin_view_has_response_ui(): void + { + $viewPath = resource_path('views/admin/release-reports/index.blade.php'); + $scriptPath = resource_path('js/alpine/components/release-report.js'); + + $this->assertFileExists($viewPath); + $this->assertFileExists($scriptPath); + + $view = file_get_contents($viewPath); + $script = file_get_contents($scriptPath); + + $this->assertStringContainsString('reportResponseModal', $view); + $this->assertStringContainsString('reportResponseForm', $view); + $this->assertStringContainsString('response-report-btn', $view); + $this->assertStringContainsString('admin.release-reports.update-response', $view); + $this->assertStringContainsString('showResponse(', $script); + $this->assertStringContainsString('submitResponse()', $script); + } + + /** + * Test that details and browse views expose public report responses. + */ + public function test_details_and_browse_views_show_report_responses(): void + { + $detailsControllerPath = app_path('Http/Controllers/DetailsController.php'); + $detailsViewPath = resource_path('views/details/index.blade.php'); + $browseViewPath = resource_path('views/browse/index.blade.php'); + $browseServicePath = app_path('Services/Releases/ReleaseBrowseService.php'); + + $this->assertFileExists($detailsControllerPath); + $this->assertFileExists($detailsViewPath); + $this->assertFileExists($browseViewPath); + $this->assertFileExists($browseServicePath); + + $detailsController = file_get_contents($detailsControllerPath); + $detailsView = file_get_contents($detailsViewPath); + $browseView = file_get_contents($browseViewPath); + $browseService = file_get_contents($browseServicePath); + + $this->assertStringContainsString('publicReportResponses', $detailsController); + $this->assertStringContainsString("where('response_is_public', true)", $detailsController); + $this->assertStringContainsString('Staff response', $detailsView); + $this->assertStringContainsString('report_response_count', $browseService); + $this->assertStringContainsString('response_is_public = 1', $browseService); + $this->assertStringContainsString('Staff response available on release details', $browseView); + } }