mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Add release report response
This commit is contained in:
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
use App\Http\Controllers\BasePageController;
|
use App\Http\Controllers\BasePageController;
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\ReleaseReport;
|
use App\Models\ReleaseReport;
|
||||||
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -51,10 +52,48 @@ class AdminReleaseReportController extends BasePageController
|
|||||||
'reviewed_by' => Auth::id(),
|
'reviewed_by' => Auth::id(),
|
||||||
'reviewed_at' => now(),
|
'reviewed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Report status updated successfully.');
|
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.
|
* Delete the reported release and resolve the report.
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +117,7 @@ class AdminReleaseReportController extends BasePageController
|
|||||||
'reviewed_by' => Auth::id(),
|
'reviewed_by' => Auth::id(),
|
||||||
'reviewed_at' => now(),
|
'reviewed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
|
||||||
return redirect()->back()->with('success', "Release '{$releaseName}' deleted and all related reports resolved.");
|
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_by' => Auth::id(),
|
||||||
'reviewed_at' => now(),
|
'reviewed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Report dismissed successfully.');
|
return redirect()->back()->with('success', 'Report dismissed successfully.');
|
||||||
}
|
}
|
||||||
@@ -116,6 +157,7 @@ class AdminReleaseReportController extends BasePageController
|
|||||||
'reviewed_by' => Auth::id(),
|
'reviewed_by' => Auth::id(),
|
||||||
'reviewed_at' => now(),
|
'reviewed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Report reverted to reviewed status successfully.');
|
return redirect()->back()->with('success', 'Report reverted to reviewed status successfully.');
|
||||||
}
|
}
|
||||||
@@ -180,6 +222,10 @@ class AdminReleaseReportController extends BasePageController
|
|||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
}
|
||||||
|
|
||||||
$actionLabel = match ($action) {
|
$actionLabel = match ($action) {
|
||||||
'delete' => 'deleted',
|
'delete' => 'deleted',
|
||||||
'dismiss' => 'dismissed',
|
'dismiss' => 'dismissed',
|
||||||
|
|||||||
@@ -72,11 +72,21 @@ class DetailsController extends BasePageController
|
|||||||
$comments = ReleaseComment::getComments($data['id']);
|
$comments = ReleaseComment::getComments($data['id']);
|
||||||
$similars = $this->releaseSearchService->searchSimilar($data['id'], $data['searchname'], (array) $this->userdata->categoryexclusions);
|
$similars = $this->releaseSearchService->searchSimilar($data['id'], $data['searchname'], (array) $this->userdata->categoryexclusions);
|
||||||
$failed = DnzbFailure::getFailedCount($data['id']);
|
$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'])
|
->whereIn('status', ['pending', 'reviewed', 'resolved'])
|
||||||
->get();
|
->get();
|
||||||
$reportCount = $reportData->count();
|
$reportCount = $reportData->count();
|
||||||
$reportReasons = ReleaseReport::reasonKeysToLabels($reportData->pluck('reason')->unique()->implode(', '));
|
$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']);
|
$downloadedBy = UserDownload::query()->with('user')->where('releases_id', $data['id'])->get(['users_id']);
|
||||||
|
|
||||||
$showInfo = '';
|
$showInfo = '';
|
||||||
@@ -175,6 +185,7 @@ class DetailsController extends BasePageController
|
|||||||
'failed' => $failed,
|
'failed' => $failed,
|
||||||
'reportCount' => $reportCount,
|
'reportCount' => $reportCount,
|
||||||
'reportReasons' => $reportReasons,
|
'reportReasons' => $reportReasons,
|
||||||
|
'publicReportResponses' => $publicReportResponses,
|
||||||
'regex' => $releaseRegex,
|
'regex' => $releaseRegex,
|
||||||
'downloadedby' => $downloadedBy,
|
'downloadedby' => $downloadedBy,
|
||||||
'meta_title' => 'View NZB',
|
'meta_title' => 'View NZB',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\ReleaseReport;
|
use App\Models\ReleaseReport;
|
||||||
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -51,6 +52,7 @@ class ReleaseReportController extends BasePageController
|
|||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
ReleaseBrowseService::bumpCacheVersion();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ class ReleaseReport extends Model
|
|||||||
'users_id',
|
'users_id',
|
||||||
'reason',
|
'reason',
|
||||||
'description',
|
'description',
|
||||||
|
'response',
|
||||||
'status',
|
'status',
|
||||||
'reviewed_by',
|
'reviewed_by',
|
||||||
'reviewed_at',
|
'reviewed_at',
|
||||||
|
'responded_by',
|
||||||
|
'responded_at',
|
||||||
|
'response_is_public',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +40,8 @@ class ReleaseReport extends Model
|
|||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'reviewed_at' => 'datetime',
|
'reviewed_at' => 'datetime',
|
||||||
|
'responded_at' => 'datetime',
|
||||||
|
'response_is_public' => 'boolean',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
@@ -102,6 +108,16 @@ class ReleaseReport extends Model
|
|||||||
return $this->belongsTo(User::class, 'reviewed_by');
|
return $this->belongsTo(User::class, 'reviewed_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the admin who responded to the report.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function responder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'responded_by');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a paginated list of reports with optional filters.
|
* Get a paginated list of reports with optional filters.
|
||||||
*/
|
*/
|
||||||
@@ -110,7 +126,7 @@ class ReleaseReport extends Model
|
|||||||
int $perPage = 50
|
int $perPage = 50
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
$query = self::query()
|
$query = self::query()
|
||||||
->with(['release', 'user', 'reviewer'])
|
->with(['release', 'user', 'reviewer', 'responder'])
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
if ($status !== null && $status !== 'all') {
|
if ($status !== null && $status !== 'all') {
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ class ReleaseBrowseService
|
|||||||
df.failed AS failed_count,
|
df.failed AS failed_count,
|
||||||
rr.report_count AS report_count,
|
rr.report_count AS report_count,
|
||||||
rr.report_reasons AS report_reasons,
|
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,
|
rn.releases_id AS nfoid,
|
||||||
re.releases_id AS reid,
|
re.releases_id AS reid,
|
||||||
m.imdbid";
|
m.imdbid";
|
||||||
@@ -175,8 +177,8 @@ class ReleaseBrowseService
|
|||||||
LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id
|
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 video_data re ON re.releases_id = r.id
|
||||||
LEFT OUTER JOIN release_nfos rn ON rn.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 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";
|
||||||
$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';
|
$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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1903,6 +1903,8 @@ class ReleaseSearchService
|
|||||||
df.failed AS failed_count,
|
df.failed AS failed_count,
|
||||||
rr.report_count AS report_count,
|
rr.report_count AS report_count,
|
||||||
rr.report_reasons AS report_reasons,
|
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,
|
g.name AS group_name,
|
||||||
rn.releases_id AS nfoid,
|
rn.releases_id AS nfoid,
|
||||||
re.releases_id AS reid,
|
re.releases_id AS reid,
|
||||||
@@ -1915,7 +1917,7 @@ class ReleaseSearchService
|
|||||||
LEFT JOIN categories c ON c.id = r.categories_id
|
LEFT JOIN categories c ON c.id = r.categories_id
|
||||||
LEFT JOIN root_categories cp ON cp.id = c.root_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 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",
|
%s",
|
||||||
$whereSql
|
$whereSql
|
||||||
);
|
);
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('release_reports', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -113,6 +113,10 @@ Alpine.data('adminReleaseReports', () => ({
|
|||||||
revertModalOpen: false,
|
revertModalOpen: false,
|
||||||
revertActionUrl: '',
|
revertActionUrl: '',
|
||||||
revertStatus: '',
|
revertStatus: '',
|
||||||
|
responseModalOpen: false,
|
||||||
|
responseActionUrl: '',
|
||||||
|
responseContent: '',
|
||||||
|
responseIsPublic: true,
|
||||||
allChecked: false,
|
allChecked: false,
|
||||||
selectedCount: 0,
|
selectedCount: 0,
|
||||||
rootEl: null,
|
rootEl: null,
|
||||||
@@ -144,6 +148,24 @@ Alpine.data('adminReleaseReports', () => ({
|
|||||||
if (form) { form.action = this.revertActionUrl; form.submit(); }
|
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() {
|
componentRoot() {
|
||||||
return this.rootEl || this.$root;
|
return this.rootEl || this.$root;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Release</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Release</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Reporter</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Reporter</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Reason</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Reason</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Response</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||||
@@ -176,6 +177,24 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
@if($report->response)
|
||||||
|
<div class="max-w-xs space-y-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $report->response_is_public ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200' }}">
|
||||||
|
<i class="fas fa-reply mr-1"></i>
|
||||||
|
{{ $report->response_is_public ? 'Public response' : 'Private note' }}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-700 dark:text-gray-300 break-words">{{ Str::limit($report->response, 90) }}</p>
|
||||||
|
@if($report->responder && $report->responded_at)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
by {{ $report->responder->username }} at {{ $report->responded_at->format('M d, Y H:i') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">No response</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="px-4 py-4 whitespace-nowrap">
|
<td class="px-4 py-4 whitespace-nowrap">
|
||||||
@php
|
@php
|
||||||
$statusColors = [
|
$statusColors = [
|
||||||
@@ -280,11 +299,27 @@
|
|||||||
title="Revert to Reviewed for further action">
|
title="Revert to Reviewed for further action">
|
||||||
<i class="fas fa-undo mr-1"></i> Revert
|
<i class="fas fa-undo mr-1"></i> Revert
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ ucfirst($report->status) }}</span>
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium {{ $statusColors[$report->status] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200' }}">
|
||||||
|
<i class="fas {{ $statusIcons[$report->status] ?? 'fa-question' }} mr-1"></i>
|
||||||
|
{{ ucfirst($report->status) }}
|
||||||
|
</span>
|
||||||
@else
|
@else
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ ucfirst($report->status) }}</span>
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium {{ $statusColors[$report->status] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200' }}">
|
||||||
|
<i class="fas {{ $statusIcons[$report->status] ?? 'fa-question' }} mr-1"></i>
|
||||||
|
{{ ucfirst($report->status) }}
|
||||||
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="response-report-btn px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition text-sm inline-flex items-center"
|
||||||
|
data-action-url="{{ route('admin.release-reports.update-response', $report->id) }}"
|
||||||
|
data-response="{{ e($report->response ?? '') }}"
|
||||||
|
data-public="{{ $report->response_is_public ? '1' : '0' }}"
|
||||||
|
@click="showResponse($event.currentTarget.dataset.actionUrl, $event.currentTarget.dataset.response, $event.currentTarget.dataset.public === '1')"
|
||||||
|
title="{{ $report->response ? 'Edit response' : 'Add response' }}">
|
||||||
|
<i class="fas fa-reply mr-1"></i> {{ $report->response ? 'Edit Response' : 'Add Response' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
@if($report->release)
|
@if($report->release)
|
||||||
<a href="{{ url('/details/' . $report->release->guid) }}"
|
<a href="{{ url('/details/' . $report->release->guid) }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -371,6 +406,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Response Modal -->
|
||||||
|
<div id="reportResponseModal"
|
||||||
|
x-cloak
|
||||||
|
x-show="responseModalOpen"
|
||||||
|
@keydown.escape.window="closeResponse()"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="response-modal-backdrop fixed inset-0 transition-opacity bg-gray-500/75 dark:bg-gray-900/75"
|
||||||
|
@click="closeResponse()"></div>
|
||||||
|
|
||||||
|
<!-- Modal panel container -->
|
||||||
|
<div class="fixed inset-0 z-10 overflow-y-auto" @click.self="closeResponse()">
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="relative w-full max-w-2xl p-6 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-gray-800 shadow-xl rounded-lg">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
<i class="fas fa-reply text-indigo-500 mr-2"></i>Staff Response
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="response-modal-close text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" @click="closeResponse()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="reportResponseForm" x-ref="responseForm" method="POST" action="" @submit.prevent="submitResponse()">
|
||||||
|
@csrf
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="reportResponseText" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Response</label>
|
||||||
|
<textarea id="reportResponseText"
|
||||||
|
name="response"
|
||||||
|
rows="7"
|
||||||
|
maxlength="2000"
|
||||||
|
x-model="responseContent"
|
||||||
|
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
placeholder="Write the staff response shown on the release details page..."></textarea>
|
||||||
|
<div class="mt-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Leave blank and save to clear the response.</span>
|
||||||
|
<span><span x-text="responseCharCount()"></span>/2000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="hidden" name="response_is_public" value="0">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="response_is_public"
|
||||||
|
value="1"
|
||||||
|
x-model="responseIsPublic"
|
||||||
|
class="mt-1 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||||
|
<span>
|
||||||
|
Show this response publicly on release details pages and enable the response badge on browse/search results.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button type="button" class="response-modal-close px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition" @click="closeResponse()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
<i class="fas fa-save mr-1"></i> Save Response
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Revert Confirmation Modal -->
|
<!-- Revert Confirmation Modal -->
|
||||||
<div id="revertConfirmModal"
|
<div id="revertConfirmModal"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -142,6 +142,12 @@
|
|||||||
<i class="fas fa-flag mr-1"></i> Reported ({{ $result->report_count }})
|
<i class="fas fa-flag mr-1"></i> Reported ({{ $result->report_count }})
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
@if(!empty($result->report_response_count) && $result->report_response_count > 0)
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||||
|
title="Staff response available on release details">
|
||||||
|
<i class="fas fa-reply mr-1"></i> Response
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
@if(!empty($result->failed_count) && $result->failed_count > 0)
|
@if(!empty($result->failed_count) && $result->failed_count > 0)
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||||
title="{{ $result->failed_count }} user(s) reported download failure">
|
title="{{ $result->failed_count }} user(s) reported download failure">
|
||||||
@@ -283,6 +289,20 @@
|
|||||||
<a href="{{ url('/details/' . $result->guid) }}" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 font-medium wrap-break-word text-base">
|
<a href="{{ url('/details/' . $result->guid) }}" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 font-medium wrap-break-word text-base">
|
||||||
{{ $result->searchname }}
|
{{ $result->searchname }}
|
||||||
</a>
|
</a>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
@if(!empty($result->report_count) && $result->report_count > 0)
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200"
|
||||||
|
title="Reported: {{ \App\Models\ReleaseReport::reasonKeysToLabels($result->report_reasons ?? '') }}">
|
||||||
|
<i class="fas fa-flag mr-1"></i> Reported ({{ $result->report_count }})
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if(!empty($result->report_response_count) && $result->report_response_count > 0)
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||||
|
title="Staff response available on release details">
|
||||||
|
<i class="fas fa-reply mr-1"></i> Response
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex flex-wrap items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-200">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-200">
|
||||||
{{ $result->category_name ?? 'Other' }}
|
{{ $result->category_name ?? 'Other' }}
|
||||||
|
|||||||
@@ -70,6 +70,26 @@
|
|||||||
@endauth
|
@endauth
|
||||||
<x-report-button :release-id="$release->id" variant="button-lg" />
|
<x-report-button :release-id="$release->id" variant="button-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
@if(!empty($publicReportResponses) && $publicReportResponses->count() > 0)
|
||||||
|
<div class="mt-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-3 flex items-center">
|
||||||
|
<i class="fas fa-reply mr-2"></i> Staff response
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach($publicReportResponses as $responseReport)
|
||||||
|
<div class="text-sm text-blue-900 dark:text-blue-100">
|
||||||
|
<div class="whitespace-pre-wrap break-words">{{ $responseReport->response }}</div>
|
||||||
|
<div class="mt-2 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{{ $responseReport->responded_at ? 'Responded ' . $responseReport->responded_at->format('M d, Y H:i') : 'Staff response' }}
|
||||||
|
@if($responseReport->responder)
|
||||||
|
by {{ $responseReport->responder->username }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
|
|||||||
// Release Reports Management
|
// Release Reports Management
|
||||||
Route::get('release-reports', [AdminReleaseReportController::class, 'index'])->name('admin.release-reports');
|
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}/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}/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}/dismiss', [AdminReleaseReportController::class, 'dismiss'])->name('admin.release-reports.dismiss');
|
||||||
Route::post('release-reports/{id}/revert', [AdminReleaseReportController::class, 'revert'])->name('admin.release-reports.revert');
|
Route::post('release-reports/{id}/revert', [AdminReleaseReportController::class, 'revert'])->name('admin.release-reports.revert');
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ class AdminReleaseReportControllerTest extends TestCase
|
|||||||
|
|
||||||
$content = file_get_contents($servicePath);
|
$content = file_get_contents($servicePath);
|
||||||
|
|
||||||
// Check that the query includes resolved status
|
// Check that the active report count includes resolved status.
|
||||||
$this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content);
|
$this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,8 +204,8 @@ class AdminReleaseReportControllerTest extends TestCase
|
|||||||
|
|
||||||
$content = file_get_contents($servicePath);
|
$content = file_get_contents($servicePath);
|
||||||
|
|
||||||
// Check that the query includes resolved status
|
// Check that the active report count includes resolved status.
|
||||||
$this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content);
|
$this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,8 +219,8 @@ class AdminReleaseReportControllerTest extends TestCase
|
|||||||
|
|
||||||
$content = file_get_contents($servicePath);
|
$content = file_get_contents($servicePath);
|
||||||
|
|
||||||
// Check that the query includes resolved status
|
// Check that the active report count includes resolved status.
|
||||||
$this->assertStringContainsString("WHERE status IN ('pending', 'reviewed', 'resolved')", $content);
|
$this->assertStringContainsString("status IN ('pending', 'reviewed', 'resolved')", $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,4 +237,93 @@ class AdminReleaseReportControllerTest extends TestCase
|
|||||||
// Check that the query includes resolved status
|
// Check that the query includes resolved status
|
||||||
$this->assertStringContainsString("whereIn('status', ['pending', 'reviewed', 'resolved'])", $content);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user