526 lines
22 KiB
PHP
526 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Enums\UserRole;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AuthAuditLog;
|
|
use App\Models\Artwork;
|
|
use App\Models\Report;
|
|
use App\Models\Story;
|
|
use App\Models\Upload;
|
|
use App\Models\User;
|
|
use App\Support\Moderation\ReportTargetResolver;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
final class AdminController extends Controller
|
|
{
|
|
// ── Dashboard ────────────────────────────────────────────────────────────
|
|
|
|
public function dashboard(): Response
|
|
{
|
|
$stats = [
|
|
'total_users' => User::count(),
|
|
'new_users_today' => User::whereDate('created_at', today())->count(),
|
|
'staff_count' => User::whereIn('role', ['admin', 'manager', 'editorial'])->count(),
|
|
'moderator_count' => User::where('role', 'moderator')->count(),
|
|
];
|
|
|
|
return Inertia::render('Admin/Dashboard', [
|
|
'stats' => $stats,
|
|
]);
|
|
}
|
|
|
|
public function dailyActivity(Request $request, ReportTargetResolver $reportTargets): Response
|
|
{
|
|
$selectedDate = $this->resolveActivityDate($request);
|
|
$periodStart = $selectedDate->copy()->startOfDay();
|
|
$periodEnd = $selectedDate->copy()->endOfDay();
|
|
|
|
$users = User::query()
|
|
->select('id', 'name', 'username', 'email', 'role', 'created_at')
|
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orderByDesc('created_at')
|
|
->limit(25)
|
|
->get()
|
|
->map(fn (User $user): array => [
|
|
'id' => (int) $user->id,
|
|
'name' => (string) $user->name,
|
|
'username' => $user->username,
|
|
'email' => (string) $user->email,
|
|
'role' => (string) $user->role,
|
|
'created_at' => optional($user->created_at)?->toISOString(),
|
|
])
|
|
->values();
|
|
|
|
$artworks = Artwork::query()
|
|
->with('user:id,name,username')
|
|
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
|
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orderByDesc('created_at')
|
|
->limit(25)
|
|
->get()
|
|
->map(fn (Artwork $artwork): array => [
|
|
'id' => (int) $artwork->id,
|
|
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
|
|
'status' => (string) ($artwork->artwork_status ?? 'unknown'),
|
|
'thumb' => $artwork->thumbUrl('sm') ?? null,
|
|
'created_at' => optional($artwork->created_at)?->toISOString(),
|
|
'user' => $artwork->user ? [
|
|
'id' => (int) $artwork->user->id,
|
|
'name' => (string) $artwork->user->name,
|
|
'username' => $artwork->user->username,
|
|
] : null,
|
|
])
|
|
->values();
|
|
|
|
$stories = Story::query()
|
|
->with('creator:id,name,username')
|
|
->select('id', 'title', 'status', 'created_at', 'published_at', 'creator_id')
|
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orderByDesc('created_at')
|
|
->limit(25)
|
|
->get()
|
|
->map(fn (Story $story): array => [
|
|
'id' => (int) $story->id,
|
|
'title' => (string) ($story->title ?? 'Untitled story'),
|
|
'status' => (string) ($story->status ?? 'draft'),
|
|
'created_at' => optional($story->created_at)?->toISOString(),
|
|
'published_at' => optional($story->published_at)?->toISOString(),
|
|
'creator' => $story->creator ? [
|
|
'id' => (int) $story->creator->id,
|
|
'name' => (string) $story->creator->name,
|
|
'username' => $story->creator->username,
|
|
] : null,
|
|
])
|
|
->values();
|
|
|
|
$uploads = Schema::hasTable('uploads')
|
|
? Upload::query()
|
|
->select('id', 'user_id', 'type', 'status', 'processing_state', 'title', 'created_at', 'moderation_status', 'moderated_at', 'moderated_by', 'moderation_note')
|
|
->where(function ($query) use ($periodStart, $periodEnd): void {
|
|
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orWhereBetween('moderated_at', [$periodStart, $periodEnd]);
|
|
})
|
|
->orderByDesc('created_at')
|
|
->limit(40)
|
|
->get()
|
|
->map(fn (Upload $upload): array => [
|
|
'id' => (string) $upload->id,
|
|
'user_id' => $upload->user_id !== null ? (int) $upload->user_id : null,
|
|
'title' => (string) ($upload->title ?? 'Untitled upload'),
|
|
'type' => (string) ($upload->type ?? 'unknown'),
|
|
'status' => (string) ($upload->status ?? 'unknown'),
|
|
'processing_state' => (string) ($upload->processing_state ?? 'unknown'),
|
|
'moderation_status' => (string) ($upload->moderation_status ?? 'unknown'),
|
|
'created_at' => optional($upload->created_at)?->toISOString(),
|
|
'moderated_at' => optional($upload->moderated_at)?->toISOString(),
|
|
'moderated_by' => $upload->moderated_by !== null ? (int) $upload->moderated_by : null,
|
|
'moderation_note' => $upload->moderation_note,
|
|
])
|
|
->values()
|
|
: collect();
|
|
|
|
$reports = Schema::hasTable('reports')
|
|
? Report::query()
|
|
->with(['reporter:id,username', 'lastModeratedBy:id,username'])
|
|
->where(function ($query) use ($periodStart, $periodEnd): void {
|
|
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orWhereBetween('last_moderated_at', [$periodStart, $periodEnd]);
|
|
})
|
|
->orderByDesc('created_at')
|
|
->limit(30)
|
|
->get()
|
|
->map(fn (Report $report): array => [
|
|
'id' => (int) $report->id,
|
|
'status' => (string) $report->status,
|
|
'reason' => (string) $report->reason,
|
|
'target_type' => (string) $report->target_type,
|
|
'target_id' => (int) $report->target_id,
|
|
'created_at' => optional($report->created_at)?->toISOString(),
|
|
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
|
|
'moderator_note' => $report->moderator_note,
|
|
'reporter' => $report->reporter ? [
|
|
'id' => (int) $report->reporter->id,
|
|
'username' => (string) $report->reporter->username,
|
|
] : null,
|
|
'last_moderated_by' => $report->lastModeratedBy ? [
|
|
'id' => (int) $report->lastModeratedBy->id,
|
|
'username' => (string) $report->lastModeratedBy->username,
|
|
] : null,
|
|
'target' => $reportTargets->summarize($report),
|
|
])
|
|
->values()
|
|
: collect();
|
|
|
|
$usernameRequests = Schema::hasTable('username_approval_requests')
|
|
? (function () use ($periodStart, $periodEnd) {
|
|
$requestColumns = Schema::getColumnListing('username_approval_requests');
|
|
$selects = [
|
|
'requests.id',
|
|
'requests.user_id',
|
|
'requests.requested_username',
|
|
'requests.status',
|
|
'requests.created_at',
|
|
'users.username as current_username',
|
|
'users.name as current_name',
|
|
];
|
|
|
|
if (in_array('context', $requestColumns, true)) {
|
|
$selects[] = 'requests.context';
|
|
}
|
|
|
|
if (in_array('similar_to', $requestColumns, true)) {
|
|
$selects[] = 'requests.similar_to';
|
|
}
|
|
|
|
if (in_array('review_note', $requestColumns, true)) {
|
|
$selects[] = 'requests.review_note';
|
|
}
|
|
|
|
if (in_array('reviewed_at', $requestColumns, true)) {
|
|
$selects[] = 'requests.reviewed_at';
|
|
}
|
|
|
|
$query = DB::table('username_approval_requests as requests')
|
|
->leftJoin('users', 'users.id', '=', 'requests.user_id')
|
|
->select($selects)
|
|
->where(function (Builder $query) use ($periodStart, $periodEnd, $requestColumns): void {
|
|
$query->whereBetween('requests.created_at', [$periodStart, $periodEnd]);
|
|
|
|
if (in_array('reviewed_at', $requestColumns, true)) {
|
|
$query->orWhereBetween('requests.reviewed_at', [$periodStart, $periodEnd]);
|
|
}
|
|
})
|
|
->orderByDesc('requests.created_at')
|
|
->limit(30);
|
|
|
|
return $query
|
|
->get()
|
|
->map(fn ($row): array => [
|
|
'id' => (int) $row->id,
|
|
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
|
|
'requested_username' => (string) $row->requested_username,
|
|
'status' => (string) ($row->status ?? 'pending'),
|
|
'context' => $row->context ?? null,
|
|
'similar_to' => $row->similar_to ?? null,
|
|
'reason' => $row->review_note ?? null,
|
|
'created_at' => $this->serializeDatabaseTimestamp($row->created_at),
|
|
'reviewed_at' => $this->serializeDatabaseTimestamp($row->reviewed_at ?? null),
|
|
'current_username' => $row->current_username,
|
|
'current_name' => $row->current_name,
|
|
])
|
|
->values();
|
|
})()
|
|
: collect();
|
|
|
|
$authEvents = Schema::hasTable('auth_audit_logs')
|
|
? AuthAuditLog::query()
|
|
->with('user:id,name,username,email,role')
|
|
->select('id', 'user_id', 'event_type', 'identifier', 'status', 'reason', 'ip', 'created_at')
|
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
|
->orderByDesc('created_at')
|
|
->limit(30)
|
|
->get()
|
|
->map(fn (AuthAuditLog $log): array => [
|
|
'id' => (int) $log->id,
|
|
'event_type' => (string) $log->event_type,
|
|
'identifier' => $log->identifier,
|
|
'status' => (string) $log->status,
|
|
'reason' => $log->reason,
|
|
'ip' => $log->ip,
|
|
'created_at' => optional($log->created_at)?->toISOString(),
|
|
'user' => $log->user ? [
|
|
'id' => (int) $log->user->id,
|
|
'name' => (string) $log->user->name,
|
|
'username' => $log->user->username,
|
|
'email' => (string) $log->user->email,
|
|
'role' => (string) $log->user->role,
|
|
] : null,
|
|
])
|
|
->values()
|
|
: collect();
|
|
|
|
return Inertia::render('Admin/DailyActivity', [
|
|
'selectedDate' => $selectedDate->toDateString(),
|
|
'summary' => [
|
|
'new_users' => $users->count(),
|
|
'new_artworks' => $artworks->count(),
|
|
'new_stories' => $stories->count(),
|
|
'upload_events' => $uploads->count(),
|
|
'report_events' => $reports->count(),
|
|
'username_events' => $usernameRequests->count(),
|
|
'auth_events' => $authEvents->count(),
|
|
'moderated_uploads' => $uploads->filter(fn (array $upload): bool => ! empty($upload['moderated_at']))->count(),
|
|
'moderated_reports' => $reports->filter(fn (array $report): bool => ! empty($report['last_moderated_at']))->count(),
|
|
],
|
|
'queues' => [
|
|
'pending_uploads' => Schema::hasTable('uploads')
|
|
? Upload::query()->where('status', 'draft')->where('moderation_status', 'pending')->count()
|
|
: 0,
|
|
'open_reports' => Schema::hasTable('reports')
|
|
? Report::query()->where('status', 'open')->count()
|
|
: 0,
|
|
'pending_username_requests' => Schema::hasTable('username_approval_requests')
|
|
? DB::table('username_approval_requests')->where('status', 'pending')->count()
|
|
: 0,
|
|
],
|
|
'sections' => [
|
|
'users' => $users,
|
|
'artworks' => $artworks,
|
|
'stories' => $stories,
|
|
'uploads' => $uploads,
|
|
'reports' => $reports,
|
|
'username_requests' => $usernameRequests,
|
|
'auth_events' => $authEvents,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────
|
|
|
|
public function users(Request $request): Response
|
|
{
|
|
$search = $request->string('search')->trim()->toString();
|
|
$roleFilter = $request->string('role')->trim()->toString();
|
|
|
|
$query = User::select('id', 'name', 'username', 'email', 'role', 'created_at', 'is_active')
|
|
->orderByDesc('created_at');
|
|
|
|
if ($search !== '') {
|
|
$query->where(function ($q) use ($search): void {
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('username', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
if ($roleFilter !== '' && $roleFilter !== 'all') {
|
|
$query->where('role', $roleFilter);
|
|
}
|
|
|
|
$users = $query->paginate(50)->withQueryString();
|
|
|
|
return Inertia::render('Admin/Users/Index', [
|
|
'users' => $users,
|
|
'filters' => ['search' => $search, 'role' => $roleFilter],
|
|
'roles' => collect(UserRole::cases())->map(fn ($r) => [
|
|
'value' => $r->value,
|
|
'label' => $r->label(),
|
|
'badge' => $r->badgeClass(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// ── Promote / Demote ──────────────────────────────────────────────────────
|
|
|
|
public function updateRole(Request $request, User $user): RedirectResponse
|
|
{
|
|
$request->validate([
|
|
'role' => ['required', 'string', 'in:' . implode(',', array_column(UserRole::cases(), 'value'))],
|
|
]);
|
|
|
|
/** @var \App\Models\User $actor */
|
|
$actor = $request->user();
|
|
|
|
// Only admins can set the 'admin' role.
|
|
if ($request->input('role') === UserRole::Admin->value && ! $actor->isAdmin()) {
|
|
abort(403, 'Only admins can grant the Admin role.');
|
|
}
|
|
|
|
// Prevent self-demotion.
|
|
if ($actor->id === $user->id) {
|
|
return back()->with('error', 'You cannot change your own role.');
|
|
}
|
|
|
|
$user->update(['role' => $request->input('role')]);
|
|
|
|
return back()->with('success', "Role updated to \"{$request->input('role')}\" for {$user->name}.");
|
|
}
|
|
|
|
// ── Stories ───────────────────────────────────────────────────────────────
|
|
|
|
public function stories(Request $request): Response
|
|
{
|
|
$stories = Story::with('creator:id,name,username')
|
|
->select('id', 'title', 'status', 'published_at', 'creator_id')
|
|
->orderByDesc('created_at')
|
|
->paginate(50)
|
|
->withQueryString();
|
|
|
|
return Inertia::render('Admin/Stories', [
|
|
'stories' => $stories,
|
|
]);
|
|
}
|
|
|
|
// ── Artworks ──────────────────────────────────────────────────────────────
|
|
|
|
public function artworks(Request $request): Response
|
|
{
|
|
$artworks = Artwork::with('user:id,name,username')
|
|
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
|
|
->orderByDesc('created_at')
|
|
->paginate(50)
|
|
->withQueryString();
|
|
|
|
// Normalise status field and add thumb URL
|
|
$artworks->getCollection()->transform(function ($artwork) {
|
|
return [
|
|
'id' => $artwork->id,
|
|
'title' => $artwork->title,
|
|
'status' => $artwork->artwork_status,
|
|
'thumb' => $artwork->thumbUrl('sm') ?? null,
|
|
'created_at' => $artwork->created_at,
|
|
'user' => $artwork->user,
|
|
];
|
|
});
|
|
|
|
return Inertia::render('Admin/Artworks', [
|
|
'artworks' => $artworks,
|
|
]);
|
|
}
|
|
|
|
// ── Username Queue ────────────────────────────────────────────────────────
|
|
|
|
public function usernameQueue(): Response
|
|
{
|
|
return Inertia::render('Admin/UsernameQueue');
|
|
}
|
|
|
|
// ── Upload Queue ──────────────────────────────────────────────────────────
|
|
|
|
public function uploadQueue(): Response
|
|
{
|
|
return Inertia::render('Admin/UploadQueue');
|
|
}
|
|
|
|
// ── Settings ──────────────────────────────────────────────────────────────
|
|
|
|
public function settings(): Response
|
|
{
|
|
return Inertia::render('Admin/Settings', [
|
|
'settings' => [],
|
|
]);
|
|
}
|
|
|
|
public function authAudit(Request $request): Response
|
|
{
|
|
abort_unless($request->user()?->isAdmin(), 403, 'Only admins can access this area.');
|
|
|
|
$search = $request->string('search')->trim()->toString();
|
|
$eventType = $request->string('event')->trim()->toString();
|
|
$status = $request->string('status')->trim()->toString();
|
|
|
|
$query = AuthAuditLog::query()
|
|
->with('user:id,name,username,email,role')
|
|
->latest('created_at')
|
|
->latest('id');
|
|
|
|
if ($search !== '') {
|
|
$query->where(function ($builder) use ($search): void {
|
|
$builder
|
|
->where('identifier', 'like', "%{$search}%")
|
|
->orWhere('ip', 'like', "%{$search}%")
|
|
->orWhere('reason', 'like', "%{$search}%")
|
|
->orWhereHas('user', function ($userQuery) use ($search): void {
|
|
$userQuery
|
|
->where('name', 'like', "%{$search}%")
|
|
->orWhere('username', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
if ($eventType !== '' && $eventType !== 'all') {
|
|
$query->where('event_type', $eventType);
|
|
}
|
|
|
|
if ($status !== '' && $status !== 'all') {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
$logs = $query->paginate(50)->withQueryString()->through(function (AuthAuditLog $log): array {
|
|
return [
|
|
'id' => $log->id,
|
|
'event_type' => $log->event_type,
|
|
'identifier' => $log->identifier,
|
|
'status' => $log->status,
|
|
'reason' => $log->reason,
|
|
'ip' => $log->ip,
|
|
'user_agent' => $log->user_agent,
|
|
'metadata' => $log->metadata ?? [],
|
|
'created_at' => $log->created_at,
|
|
'user' => $log->user ? [
|
|
'id' => $log->user->id,
|
|
'name' => $log->user->name,
|
|
'username' => $log->user->username,
|
|
'email' => $log->user->email,
|
|
'role' => $log->user->role,
|
|
] : null,
|
|
];
|
|
});
|
|
|
|
return Inertia::render('Admin/AuthAudit', [
|
|
'logs' => $logs,
|
|
'filters' => [
|
|
'search' => $search,
|
|
'event' => $eventType,
|
|
'status' => $status,
|
|
],
|
|
'eventOptions' => [
|
|
['value' => 'all', 'label' => 'All events'],
|
|
['value' => 'login', 'label' => 'Login'],
|
|
['value' => 'register', 'label' => 'Register'],
|
|
['value' => 'forgot_password', 'label' => 'Forgot password'],
|
|
['value' => 'reset_password', 'label' => 'Reset password'],
|
|
],
|
|
'statusOptions' => [
|
|
['value' => 'all', 'label' => 'All statuses'],
|
|
['value' => 'success', 'label' => 'Success'],
|
|
['value' => 'failed', 'label' => 'Failed'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function resolveActivityDate(Request $request): Carbon
|
|
{
|
|
$date = $request->string('date')->trim()->toString();
|
|
|
|
if ($date === '') {
|
|
return today();
|
|
}
|
|
|
|
try {
|
|
return Carbon::createFromFormat('Y-m-d', $date)->startOfDay();
|
|
} catch (\Throwable) {
|
|
return today();
|
|
}
|
|
}
|
|
|
|
private function serializeDatabaseTimestamp(mixed $value): ?string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($value instanceof Carbon) {
|
|
return $value->toISOString();
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse((string) $value)->toISOString();
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|