Refactor dashboard and upload flows

Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
This commit is contained in:
2026-03-21 11:02:22 +01:00
parent 29c3ff8572
commit 979e011257
55 changed files with 2576 additions and 1923 deletions

View File

@@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Finder\Finder;
class AuditMigrationSchemaCommand extends Command
{
protected $signature = 'schema:audit-migrations
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
{--json : Output the report as JSON}
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
private const NO_ARG_COLUMN_METHODS = [
'id' => ['id'],
'timestamps' => ['created_at', 'updated_at'],
'timestampsTz' => ['created_at', 'updated_at'],
'softDeletes' => ['deleted_at'],
'softDeletesTz' => ['deleted_at'],
'rememberToken' => ['remember_token'],
];
private const NON_COLUMN_METHODS = [
'index',
'unique',
'primary',
'foreign',
'foreignIdFor',
'dropColumn',
'dropColumns',
'dropIndex',
'dropUnique',
'dropPrimary',
'dropForeign',
'dropConstrainedForeignId',
'renameColumn',
'renameIndex',
'constrained',
'cascadeOnDelete',
'restrictOnDelete',
'nullOnDelete',
'cascadeOnUpdate',
'restrictOnUpdate',
'nullOnUpdate',
'after',
'nullable',
'default',
'useCurrent',
'useCurrentOnUpdate',
'comment',
'charset',
'collation',
'storedAs',
'virtualAs',
'generatedAs',
'always',
'invisible',
'first',
];
public function handle(): int
{
$migrationFiles = $this->discoverMigrationFiles();
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
->mapWithKeys(fn (string $migration): array => [$migration => true])
->all();
$expected = [];
$parsedFiles = 0;
foreach ($migrationFiles as $migrationName => $path) {
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
continue;
}
$parsedFiles++;
$operations = $this->parseMigrationFile($path);
foreach ($operations as $operation) {
$table = $operation['table'];
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
$expected[$table]['sources'][$migrationName] = true;
if (Schema::hasTable($table)) {
$actualColumns = array_fill_keys(
array_map('strtolower', Schema::getColumnListing($table)),
true
);
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
$replacementColumns = [];
foreach ($operation['add'] as $column) {
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
$replacementColumns[$column] = true;
}
}
if ($replacementColumns !== []) {
foreach ($replacementColumns as $column => $_) {
$expected[$table]['columns'][$column] = true;
}
foreach (array_keys($expected[$table]['columns']) as $column) {
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
unset($expected[$table]['columns'][$column]);
}
}
}
}
continue;
}
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
continue;
}
$expected[$table] ??= [
'columns' => [],
'sources' => [],
];
$expected[$table]['sources'][$migrationName] = true;
if ($operation['type'] === 'drop-table') {
unset($expected[$table]);
continue;
}
foreach ($operation['add'] as $column) {
$expected[$table]['columns'][$column] = true;
}
foreach ($operation['drop'] as $column) {
unset($expected[$table]['columns'][$column]);
}
}
}
ksort($expected);
$report = [
'parsed_files' => $parsedFiles,
'expected_tables' => count($expected),
'missing_tables' => [],
'missing_columns' => [],
];
foreach ($expected as $table => $spec) {
$sources = array_keys($spec['sources']);
sort($sources);
if (! Schema::hasTable($table)) {
$report['missing_tables'][] = [
'table' => $table,
'sources' => $sources,
];
continue;
}
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
$expectedColumns = array_keys($spec['columns']);
sort($expectedColumns);
$missing = array_values(array_diff($expectedColumns, $actualColumns));
if ($missing !== []) {
$report['missing_columns'][] = [
'table' => $table,
'columns' => $missing,
'sources' => $sources,
];
}
}
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderReport($report);
}
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
? self::SUCCESS
: self::FAILURE;
}
/**
* @return array<string, string>
*/
private function discoverMigrationFiles(): array
{
$paths = [
database_path('migrations'),
base_path('packages/klevze'),
];
foreach ((array) $this->option('base-path') as $relativePath) {
$resolved = base_path((string) $relativePath);
if (is_dir($resolved)) {
$paths[] = $resolved;
}
}
$finder = new Finder();
$finder->files()->name('*.php');
foreach ($paths as $path) {
if (is_dir($path)) {
$finder->in($path);
}
}
$files = [];
foreach ($finder as $file) {
$realPath = $file->getRealPath();
if (! $realPath) {
continue;
}
$normalized = str_replace('\\', '/', $realPath);
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
continue;
}
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
}
ksort($files);
return $files;
}
/**
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
*/
private function parseMigrationFile(string $path): array
{
$content = File::get($path);
$upBody = $this->extractMethodBody($content, 'up');
if ($upBody === null) {
return [];
}
$operations = [];
foreach ($this->extractSchemaClosures($upBody) as $closure) {
$operations[] = [
'type' => $closure['operation'],
'table' => $closure['table'],
'add' => $this->extractAddedColumns($closure['body']),
'drop' => $this->extractDroppedColumns($closure['body']),
];
}
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
foreach ($matches[1] as $table) {
$operations[] = [
'type' => 'drop-table',
'table' => strtolower((string) $table),
'add' => [],
'drop' => [],
];
}
}
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
$operations[] = [
'type' => 'alter-table',
'table' => $change['table'],
'add' => [$change['new_column']],
'drop' => [$change['old_column']],
];
}
return $operations;
}
/**
* @return array<int, array{table:string, old_column:string, new_column:string}>
*/
private function extractRawAlterTableChanges(string $upBody): array
{
$changes = [];
if (preg_match_all(
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
$upBody,
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$oldColumn = strtolower((string) $match[2]);
$newColumn = strtolower((string) $match[3]);
if ($oldColumn === $newColumn) {
continue;
}
$changes[] = [
'table' => strtolower((string) $match[1]),
'old_column' => $oldColumn,
'new_column' => $newColumn,
];
}
}
return $changes;
}
private function extractMethodBody(string $content, string $method): ?string
{
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
$start = $match[0][1] + strlen($match[0][0]) - 1;
$end = $this->findMatchingBrace($content, $start);
if ($end === null) {
return null;
}
return substr($content, $start + 1, $end - $start - 1);
}
private function findMatchingBrace(string $content, int $openingBracePos): ?int
{
$length = strlen($content);
$depth = 0;
$inSingle = false;
$inDouble = false;
for ($index = $openingBracePos; $index < $length; $index++) {
$char = $content[$index];
$prev = $index > 0 ? $content[$index - 1] : '';
if ($char === "'" && ! $inDouble && $prev !== '\\') {
$inSingle = ! $inSingle;
continue;
}
if ($char === '"' && ! $inSingle && $prev !== '\\') {
$inDouble = ! $inDouble;
continue;
}
if ($inSingle || $inDouble) {
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return $index;
}
}
}
return null;
}
/**
* @return array<int, array{operation:string, table:string, body:string}>
*/
private function extractSchemaClosures(string $upBody): array
{
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
$closures = [];
foreach ($matches[0] as $index => $fullMatch) {
$offset = (int) $fullMatch[1];
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
$table = strtolower((string) $matches[2][$index][0]);
$bracePos = strpos($upBody, '{', $offset);
if ($bracePos === false) {
continue;
}
$closing = $this->findMatchingBrace($upBody, $bracePos);
if ($closing === null) {
continue;
}
$closures[] = [
'operation' => $operation,
'table' => $table,
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
];
}
return $closures;
}
/**
* @return array<int, string>
*/
private function extractAddedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
$column = strtolower((string) $match[2]);
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
continue;
}
$columns[$column] = true;
}
}
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
$columns[$column] = true;
}
}
}
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$prefix = strtolower((string) $match[2]);
$columns[$prefix . '_type'] = true;
$columns[$prefix . '_id'] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @return array<int, string>
*/
private function extractDroppedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
foreach ($matches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
foreach ($matches[1] as $arrayBody) {
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
foreach ($columnMatches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
}
}
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$columns[strtolower((string) $match[1])] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
*/
private function renderReport(array $report): void
{
$this->info(sprintf(
'Parsed %d migration file(s). Expected schema covers %d table(s).',
$report['parsed_files'],
$report['expected_tables']
));
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
$this->info('Schema audit passed. No missing tables or columns detected.');
return;
}
if ($report['missing_tables'] !== []) {
$this->newLine();
$this->error('Missing tables:');
foreach ($report['missing_tables'] as $item) {
$this->line(sprintf(' - %s', $item['table']));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
if ($report['missing_columns'] !== []) {
$this->newLine();
$this->error('Missing columns:');
foreach ($report['missing_columns'] as $item) {
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
}
}

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Country;
use App\Services\Countries\CountrySyncService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Throwable;
final class CountryAdminController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->query('q', ''));
$countries = Country::query()
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($countryQuery) use ($search): void {
$countryQuery
->where('iso2', 'like', '%'.$search.'%')
->orWhere('iso3', 'like', '%'.$search.'%')
->orWhere('name_common', 'like', '%'.$search.'%')
->orWhere('name_official', 'like', '%'.$search.'%');
});
})
->ordered()
->paginate(50)
->withQueryString();
return view('admin.countries.index', [
'countries' => $countries,
'search' => $search,
]);
}
public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.countries.index')
->with('error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.countries.index')
->with('success', $message);
}
public function cpMain(Request $request): View
{
$view = $this->index($request);
return view('admin.countries.cpad', $view->getData());
}
public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.cp.countries.main')
->with('msg_error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.cp.countries.main')
->with('msg_success', $message);
}
}

View File

@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\EarlyGrowth\ActivityLayer;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\EarlyGrowth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* EarlyGrowthAdminController (§14)
*
* Admin panel for the Early-Stage Growth System.
* All toggles are ENV-driven; updating .env requires a deploy.
* This panel provides a read-only status view plus a cache-flush action.
*
* Future v2: wire to a `settings` DB table so admins can toggle without
* a deploy. The EarlyGrowth::enabled() contract already supports this.
*/
final class EarlyGrowthAdminController extends Controller
{
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ActivityLayer $activityLayer,
) {}
/**
* GET /admin/early-growth
* Status dashboard: shows current config, live stats, toggle instructions.
*/
public function index(): View
{
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
return view('admin.early-growth.index', [
'status' => EarlyGrowth::status(),
'mode' => EarlyGrowth::mode(),
'uploads_per_day' => $uploadsPerDay,
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
'activity' => $this->activityLayer->getSignals(),
'cache_keys' => [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.spotlight.*',
'egs.curated.*',
'egs.grid_filler.*',
'egs.activity_signals',
'homepage.fresh.*',
'discover.trending.*',
'discover.rising.*',
],
'env_toggles' => [
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
],
]);
}
/**
* DELETE /admin/early-growth/cache
* Flush all EGS-related cache keys so new config changes take effect immediately.
*/
public function flushCache(Request $request): RedirectResponse
{
$keys = [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.activity_signals',
];
// Flush the EGS daily spotlight caches for today
$today = now()->format('Y-m-d');
foreach ([6, 12, 18, 24] as $n) {
Cache::forget("egs.spotlight.{$today}.{$n}");
Cache::forget("egs.curated.{$today}.{$n}.7");
}
// Flush fresh/trending homepage sections
foreach ([6, 8, 10, 12] as $limit) {
foreach (['off', 'light', 'aggressive'] as $mode) {
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
Cache::forget("homepage.fresh.{$limit}.std");
}
Cache::forget("homepage.trending.{$limit}");
Cache::forget("homepage.rising.{$limit}");
}
// Flush key keys
foreach ($keys as $key) {
Cache::forget($key);
}
return redirect()->route('admin.early-growth.index')
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
}
/**
* GET /admin/early-growth/status (JSON for monitoring/healthcheck)
*/
public function status(): JsonResponse
{
return response()->json([
'egs' => EarlyGrowth::status(),
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
]);
}
}

View File

@@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use App\Services\StoryPublicationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StoryAdminController extends Controller
{
public function index(): View
{
$stories = Story::query()
->with(['creator'])
->latest('created_at')
->paginate(25);
return view('admin.stories.index', ['stories' => $stories]);
}
public function review(): View
{
$stories = Story::query()
->with(['creator'])
->where('status', 'pending_review')
->orderByDesc('submitted_for_review_at')
->paginate(25);
return view('admin.stories.review', ['stories' => $stories]);
}
public function create(): View
{
return view('admin.stories.create', [
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story = Story::query()->create([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['title']),
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? now() : null,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null,
]);
if (! empty($validated['tags'])) {
$story->tags()->sync($validated['tags']);
}
if ($validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.');
}
public function edit(Story $story): View
{
$story->load('tags');
return view('admin.stories.edit', [
'story' => $story,
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function update(Request $request, Story $story): RedirectResponse
{
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story->update([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
$story->tags()->sync($validated['tags'] ?? []);
if (! $wasPublished && $validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.');
}
public function destroy(Story $story): RedirectResponse
{
$story->delete();
return redirect()->route('admin.stories.index')->with('status', 'Story deleted.');
}
public function publish(Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'published', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
]);
return back()->with('status', 'Story published.');
}
public function show(Story $story): View
{
return view('admin.stories.show', [
'story' => $story->load(['creator', 'tags']),
]);
}
public function approve(Request $request, Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'approved', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null,
]);
return back()->with('status', 'Story approved and published.');
}
public function reject(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
$story->update([
'status' => 'rejected',
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => $validated['reason'],
]);
$story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason']));
return back()->with('status', 'Story rejected and creator notified.');
}
public function moderateComments(): View
{
return view('admin.stories.comments-moderation');
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$n = 2;
while (Story::query()->where('slug', $slug)->exists()) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): View
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 15);
abort_if($from > $to, 422, 'Invalid date range.');
$report = $this->reportService->buildReport($from, $to, $limit);
return view('admin.reports.tags', [
'filters' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
],
'overview' => $report['overview'],
'dailyClicks' => $report['daily_clicks'],
'bySurface' => $report['by_surface'],
'topTags' => $report['top_tags'],
'topQueries' => $report['top_queries'],
'topTransitions' => $report['top_transitions'],
'latestAggregatedDate' => $report['latest_aggregated_date'],
]);
}
}

View File

@@ -11,37 +11,93 @@ class FollowerController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$user = $request->user();
$perPage = 30;
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'recent');
$relationship = (string) $request->query('relationship', 'all');
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
$allowedRelationships = ['all', 'following-back', 'not-followed'];
if (! in_array($sort, $allowedSorts, true)) {
$sort = 'recent';
}
if (! in_array($relationship, $allowedRelationships, true)) {
$relationship = 'all';
}
// People who follow $user (user_id = $user being followed)
$followers = DB::table('user_followers as uf')
$baseQuery = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
$join->on('mutual.user_id', '=', 'uf.follower_id')
->where('mutual.follower_id', '=', $user->id);
})
->where('uf.user_id', $user->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($inner) use ($search): void {
$inner->where('u.username', 'like', '%' . $search . '%')
->orWhere('u.name', 'like', '%' . $search . '%');
});
})
->when($relationship === 'following-back', function ($query): void {
$query->whereNotNull('mutual.created_at');
})
->when($relationship === 'not-followed', function ($query): void {
$query->whereNull('mutual.created_at');
});
$summaryBaseQuery = clone $baseQuery;
$followers = $baseQuery
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'us.uploads_count',
'us.followers_count',
'uf.created_at as followed_at',
'mutual.created_at as followed_back_at',
])
->paginate($perPage)
->withQueryString()
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'id' => $row->id,
'name' => $row->name,
'username' => $row->username,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'uploads' => $row->uploads_count ?? 0,
'followers_count' => $row->followers_count ?? 0,
'is_following_back' => $row->followed_back_at !== null,
'followed_back_at' => $row->followed_back_at,
'followed_at' => $row->followed_at,
]);
$summary = [
'total_followers' => (clone $summaryBaseQuery)->count(),
'following_back' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
'not_followed' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
];
return view('dashboard.followers', [
'followers' => $followers,
'followers' => $followers,
'filters' => [
'q' => $search,
'sort' => $sort,
'relationship' => $relationship,
],
'summary' => $summary,
'page_title' => 'My Followers',
]);
}

View File

@@ -11,40 +11,93 @@ class FollowingController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$user = $request->user();
$perPage = 30;
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'recent');
$relationship = (string) $request->query('relationship', 'all');
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
$allowedRelationships = ['all', 'mutual', 'one-way'];
if (! in_array($sort, $allowedSorts, true)) {
$sort = 'recent';
}
if (! in_array($relationship, $allowedRelationships, true)) {
$relationship = 'all';
}
// People that $user follows (follower_id = $user)
$following = DB::table('user_followers as uf')
$baseQuery = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
$join->on('mutual.follower_id', '=', 'uf.user_id')
->where('mutual.user_id', '=', $user->id);
})
->where('uf.follower_id', $user->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($inner) use ($search): void {
$inner->where('u.username', 'like', '%' . $search . '%')
->orWhere('u.name', 'like', '%' . $search . '%');
});
})
->when($relationship === 'mutual', function ($query): void {
$query->whereNotNull('mutual.created_at');
})
->when($relationship === 'one-way', function ($query): void {
$query->whereNull('mutual.created_at');
});
$summaryBaseQuery = clone $baseQuery;
$following = $baseQuery
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'us.uploads_count',
'us.followers_count',
'uf.created_at as followed_at',
'mutual.created_at as follows_you_at',
])
->paginate($perPage)
->withQueryString()
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'name' => $row->name,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count'=> $row->followers_count ?? 0,
'followed_at' => $row->followed_at,
'id' => $row->id,
'username' => $row->username,
'name' => $row->name,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count' => $row->followers_count ?? 0,
'follows_you' => $row->follows_you_at !== null,
'follows_you_at' => $row->follows_you_at,
'followed_at' => $row->followed_at,
]);
$summary = [
'total_following' => (clone $summaryBaseQuery)->count(),
'mutual' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
'one_way' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
];
return view('dashboard.following', [
'following' => $following,
'following' => $following,
'filters' => [
'q' => $search,
'sort' => $sort,
'relationship' => $relationship,
],
'summary' => $summary,
'page_title' => 'People I Follow',
]);
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
class StaffApplicationAdminController extends Controller
{
public function index(Request $request)
{
$items = StaffApplication::orderBy('created_at', 'desc')->paginate(25);
return view('admin.staff_applications.index', ['items' => $items]);
}
public function show(StaffApplication $staffApplication)
{
return view('admin.staff_applications.show', ['item' => $staffApplication]);
}
}

View File

@@ -396,8 +396,6 @@ class AppServiceProvider extends ServiceProvider
try {
/** @var Menu $menu */
$menu = $this->app->make(Menu::class);
$menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
$menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
} catch (\Throwable) {
// Control panel menu registration should never block the app boot.
}