fix: bio public /b/{slug} route + QR types + code cleanup

- Add GET /b/{slug} route alias (nimu.li shorthand for bio pages)
- QR wizard: extend type enum to include email, phone, sms, location
- Modals: replace FQCN references with imported class aliases (Pint clean)
- Analytics: add missing Carbon/Builder imports, improve return types
- Octane config: add ResetCurrentWorkspace import
- scripts/crawl-all.sh: curl+PHPUnit based crawler (replaces Playwright)

All 158 tests pass, PHPStan clean, Pint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 16:41:05 +02:00
parent 5d90c375f7
commit 2d231fe6b8
23 changed files with 333 additions and 54 deletions

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
use App\Domains\Ai\Services\SlugSuggester; use App\Domains\Ai\Services\SlugSuggester;
use App\Domains\Link\Models\Link; use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Links\Index;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -97,6 +98,7 @@ class CreateLink extends ModalComponent
$ws = current_workspace(); $ws = current_workspace();
if (! $ws) { if (! $ws) {
$this->addError('targetUrl', 'Workspace nicht gefunden.'); $this->addError('targetUrl', 'Workspace nicht gefunden.');
return; return;
} }
$wsId = $ws->id; $wsId = $ws->id;
@ -113,7 +115,7 @@ class CreateLink extends ModalComponent
'created_by' => auth()->id(), 'created_by' => auth()->id(),
]); ]);
$this->dispatch('link-created', linkId: $link->ulid)->to(\App\Livewire\Pages\Links\Index::class); $this->dispatch('link-created', linkId: $link->ulid)->to(Index::class);
$this->dispatch('toast', message: 'Link erstellt', type: 'success'); $this->dispatch('toast', message: 'Link erstellt', type: 'success');
$this->closeModal(); $this->closeModal();
} }

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode; use App\Domains\QrCode\Models\QrCode;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\QrCodes\Index;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
@ -22,32 +23,50 @@ class CreateQrCode extends ModalComponent
// vCard // vCard
public string $vcardName = ''; public string $vcardName = '';
public string $vcardPhone = ''; public string $vcardPhone = '';
public string $vcardEmail = ''; public string $vcardEmail = '';
public string $vcardCompany = ''; public string $vcardCompany = '';
public string $vcardWebsite = ''; public string $vcardWebsite = '';
// WiFi // WiFi
public string $wifiSsid = ''; public string $wifiSsid = '';
public string $wifiPassword = ''; public string $wifiPassword = '';
public string $wifiEncryption = 'WPA'; public string $wifiEncryption = 'WPA';
public bool $wifiHidden = false; public bool $wifiHidden = false;
// Style // Style
public string $fgColor = '#000000'; public string $fgColor = '#000000';
public string $bgColor = '#ffffff'; public string $bgColor = '#ffffff';
public string $logoUrl = ''; public string $logoUrl = '';
public string $dotStyle = 'square'; public string $dotStyle = 'square';
public int $size = 512; public int $size = 512;
public int $margin = 4; public int $margin = 4;
public string $errorCorrection = 'M'; public string $errorCorrection = 'M';
public string $frame = 'none'; public string $frame = 'none';
public string $frameText = ''; public string $frameText = '';
public string $frameColor = '#7c3aed'; public string $frameColor = '#7c3aed';
// Advanced // Advanced
public bool $isDynamic = true; public bool $isDynamic = true;
public ?int $scanLimit = null; public ?int $scanLimit = null;
public string $expiresAt = ''; public string $expiresAt = '';
public function mount(string $workspaceUlid = ''): void public function mount(string $workspaceUlid = ''): void
@ -78,10 +97,10 @@ class CreateQrCode extends ModalComponent
public function buildPayload(): string public function buildPayload(): string
{ {
return match ($this->type) { return match ($this->type) {
'email' => 'mailto:' . $this->url, 'email' => 'mailto:'.$this->url,
'phone' => 'tel:' . $this->url, 'phone' => 'tel:'.$this->url,
'sms' => 'sms:' . $this->url, 'sms' => 'sms:'.$this->url,
'location' => 'geo:' . $this->url, 'location' => 'geo:'.$this->url,
'vcard' => $this->buildVcard(), 'vcard' => $this->buildVcard(),
'wifi' => $this->buildWifi(), 'wifi' => $this->buildWifi(),
default => $this->url, default => $this->url,
@ -165,7 +184,7 @@ class CreateQrCode extends ModalComponent
'expires_at' => $this->expiresAt ?: null, 'expires_at' => $this->expiresAt ?: null,
]); ]);
$this->dispatch('qr-created')->to(\App\Livewire\Pages\QrCodes\Index::class); $this->dispatch('qr-created')->to(Index::class);
$this->dispatch('toast', message: 'QR-Code erstellt', type: 'success'); $this->dispatch('toast', message: 'QR-Code erstellt', type: 'success');
$this->closeModal(); $this->closeModal();
} }

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage; use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Bio\Index;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
@ -33,7 +34,7 @@ class DeleteBioPage extends ModalComponent
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
$this->dispatch('bio-deleted')->to(\App\Livewire\Pages\Bio\Index::class); $this->dispatch('bio-deleted')->to(Index::class);
$this->dispatch('toast', message: 'Bio Page gelöscht', type: 'success'); $this->dispatch('toast', message: 'Bio Page gelöscht', type: 'success');
$this->closeModal(); $this->closeModal();
} }
@ -50,10 +51,10 @@ class DeleteBioPage extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -50,10 +50,10 @@ class DeleteDomain extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link; use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Links\Index;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
@ -33,7 +34,7 @@ class DeleteLink extends ModalComponent
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
$this->dispatch('link-deleted', linkUlid: $this->linkUlid)->to(\App\Livewire\Pages\Links\Index::class); $this->dispatch('link-deleted', linkUlid: $this->linkUlid)->to(Index::class);
$this->dispatch('toast', message: 'Link gelöscht', type: 'success'); $this->dispatch('toast', message: 'Link gelöscht', type: 'success');
$this->closeModal(); $this->closeModal();
} }
@ -50,10 +51,10 @@ class DeleteLink extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode; use App\Domains\QrCode\Models\QrCode;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\QrCodes\Index;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
@ -33,7 +34,7 @@ class DeleteQrCode extends ModalComponent
->firstOrFail() ->firstOrFail()
->delete(); ->delete();
$this->dispatch('qr-deleted')->to(\App\Livewire\Pages\QrCodes\Index::class); $this->dispatch('qr-deleted')->to(Index::class);
$this->dispatch('toast', message: 'QR Code gelöscht', type: 'success'); $this->dispatch('toast', message: 'QR Code gelöscht', type: 'success');
$this->closeModal(); $this->closeModal();
} }
@ -50,10 +51,10 @@ class DeleteQrCode extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -49,10 +49,10 @@ class DeleteWebhook extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -10,7 +10,9 @@ use LivewireUI\Modal\ModalComponent;
class LinkVariants extends ModalComponent class LinkVariants extends ModalComponent
{ {
public string $linkUlid = ''; public string $linkUlid = '';
public int $linkId = 0; public int $linkId = 0;
public string $linkTarget = ''; public string $linkTarget = '';
/** @var array<int, array{id: int|null, label: string, target_url: string, weight: int}> */ /** @var array<int, array{id: int|null, label: string, target_url: string, weight: int}> */
@ -48,7 +50,7 @@ class LinkVariants extends ModalComponent
} }
$this->variants[] = [ $this->variants[] = [
'id' => null, 'id' => null,
'label' => 'Variant ' . chr(64 + count($this->variants) + 1), 'label' => 'Variant '.chr(64 + count($this->variants) + 1),
'target_url' => '', 'target_url' => '',
'weight' => 0, 'weight' => 0,
]; ];
@ -91,6 +93,7 @@ class LinkVariants extends ModalComponent
$totalWeight = array_sum(array_column($this->variants, 'weight')); $totalWeight = array_sum(array_column($this->variants, 'weight'));
if ($totalWeight !== 100) { if ($totalWeight !== 100) {
$this->addError('variants', 'Weights must sum to 100%.'); $this->addError('variants', 'Weights must sum to 100%.');
return; return;
} }

View File

@ -49,10 +49,10 @@ class RemoveMember extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -59,10 +59,10 @@ class VerifyDomain extends ModalComponent
private function authorizeWorkspace(int $workspaceId): void private function authorizeWorkspace(int $workspaceId): void
{ {
Workspace::where("id", $workspaceId) Workspace::where('id', $workspaceId)
->where(fn ($q) => $q ->where(fn ($q) => $q
->where("owner_id", auth()->id()) ->where('owner_id', auth()->id())
->orWhereHas("members", fn ($q) => $q->where("user_id", auth()->id())) ->orWhereHas('members', fn ($q) => $q->where('user_id', auth()->id()))
)->firstOrFail(); )->firstOrFail();
} }
} }

View File

@ -10,6 +10,7 @@ use Livewire\Component;
class Anomalies extends Component class Anomalies extends Component
{ {
public string $report = ''; public string $report = '';
public bool $loading = false; public bool $loading = false;
public function detect(): void public function detect(): void

View File

@ -10,6 +10,7 @@ use Livewire\Component;
class Insights extends Component class Insights extends Component
{ {
public string $insights = ''; public string $insights = '';
public bool $loading = false; public bool $loading = false;
public function generate(): void public function generate(): void

View File

@ -4,6 +4,8 @@ namespace App\Livewire\Pages\Analytics;
use App\Domains\Analytics\Models\Click; use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link; use App\Domains\Link\Models\Link;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\View\View; use Illuminate\View\View;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
@ -24,6 +26,7 @@ class Index extends Component
public string $linkId = ''; public string $linkId = '';
public int $totalToday = 0; public int $totalToday = 0;
public int $workspaceId = 0; public int $workspaceId = 0;
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
@ -43,7 +46,7 @@ class Index extends Component
$this->range = in_array($range, ['24h', '7d', '30d', '90d']) ? $range : '7d'; $this->range = in_array($range, ['24h', '7d', '30d', '90d']) ? $range : '7d';
} }
private function rangeStart(): \Carbon\Carbon private function rangeStart(): Carbon
{ {
return match ($this->range) { return match ($this->range) {
'24h' => now()->subHours(24), '24h' => now()->subHours(24),
@ -53,7 +56,8 @@ class Index extends Component
}; };
} }
private function baseQuery(): \Illuminate\Database\Eloquent\Builder /** @return Builder<Click> */
private function baseQuery(): Builder
{ {
$workspace = require_workspace(); $workspace = require_workspace();

View File

@ -29,7 +29,7 @@ if (! function_exists('require_workspace')) {
{ {
$ws = current_workspace(); $ws = current_workspace();
if (! $ws) { if (! $ws) {
throw new \RuntimeException('No current workspace set. Route missing workspace.resolve middleware?'); throw new RuntimeException('No current workspace set. Route missing workspace.resolve middleware?');
} }
return $ws; return $ws;

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Listeners\ResetCurrentWorkspace;
use Laravel\Octane\Contracts\OperationTerminated; use Laravel\Octane\Contracts\OperationTerminated;
use Laravel\Octane\Events\RequestHandled; use Laravel\Octane\Events\RequestHandled;
use Laravel\Octane\Events\RequestReceived; use Laravel\Octane\Events\RequestReceived;
@ -73,7 +74,7 @@ return [
RequestReceived::class => [ RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(), ...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(), ...Octane::prepareApplicationForNextRequest(),
\App\Listeners\ResetCurrentWorkspace::class, ResetCurrentWorkspace::class,
], ],
RequestHandled::class => [ RequestHandled::class => [
@ -82,7 +83,7 @@ return [
RequestTerminated::class => [ RequestTerminated::class => [
// FlushUploadedFiles::class, // FlushUploadedFiles::class,
\App\Listeners\ResetCurrentWorkspace::class, ResetCurrentWorkspace::class,
], ],
TaskReceived::class => [ TaskReceived::class => [

View File

@ -1,22 +1,20 @@
<?php <?php
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (\DB::getDriverName() === 'mysql') { if (DB::getDriverName() === 'mysql') {
\DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text','email','phone','sms','location') NOT NULL DEFAULT 'url'"); DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text','email','phone','sms','location') NOT NULL DEFAULT 'url'");
} }
} }
public function down(): void public function down(): void
{ {
if (\DB::getDriverName() === 'mysql') { if (DB::getDriverName() === 'mysql') {
\DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text') NOT NULL DEFAULT 'url'"); DB::statement("ALTER TABLE qr_codes MODIFY COLUMN type ENUM('url','vcard','wifi','pdf','mp3','text') NOT NULL DEFAULT 'url'");
} }
} }
}; };

64
package-lock.json generated
View File

@ -8,6 +8,7 @@
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@ -519,6 +520,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.4", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
@ -2253,6 +2270,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",

View File

@ -10,6 +10,7 @@
"lint": "echo 'no lint configured'" "lint": "echo 'no lint configured'"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",

View File

@ -3,6 +3,9 @@
use App\Http\Controllers\BioPageController; use App\Http\Controllers\BioPageController;
use App\Http\Controllers\RedirectController; use App\Http\Controllers\RedirectController;
use App\Http\Middleware\ResolveWorkspace; use App\Http\Middleware\ResolveWorkspace;
use App\Livewire\Pages\Ai\AbGenerator;
use App\Livewire\Pages\Ai\Anomalies;
use App\Livewire\Pages\Ai\Insights;
use App\Livewire\Pages\Dashboard\Overview; use App\Livewire\Pages\Dashboard\Overview;
use App\Livewire\Pages\Links\Index; use App\Livewire\Pages\Links\Index;
use App\Livewire\Pages\Profile\PersonalInfo; use App\Livewire\Pages\Profile\PersonalInfo;
@ -54,13 +57,15 @@ Route::middleware(['auth', 'verified', ResolveWorkspace::class])
Route::get('/settings/webhooks', Webhooks::class)->name('settings.webhooks'); Route::get('/settings/webhooks', Webhooks::class)->name('settings.webhooks');
Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index'); Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index');
Route::get('/team', Members::class)->name('team.index'); Route::get('/team', Members::class)->name('team.index');
Route::get('/ai/insights', App\Livewire\Pages\Ai\Insights::class)->name('ai.insights'); Route::get('/ai/insights', Insights::class)->name('ai.insights');
Route::get('/ai/anomalies', App\Livewire\Pages\Ai\Anomalies::class)->name('ai.anomalies'); Route::get('/ai/anomalies', Anomalies::class)->name('ai.anomalies');
Route::get('/ai/ab-generator', App\Livewire\Pages\Ai\AbGenerator::class)->name('ai.ab-generator'); Route::get('/ai/ab-generator', AbGenerator::class)->name('ai.ab-generator');
}); });
// Bio page route — must be before the slug catch-all // Bio page routes — must be before the slug catch-all
// /bio/{slug} for app.nimuli.com, /b/{slug} for nimu.li shorthand
Route::get('/bio/{slug}', [BioPageController::class, 'show'])->name('bio.public'); Route::get('/bio/{slug}', [BioPageController::class, 'show'])->name('bio.public');
Route::get('/b/{slug}', [BioPageController::class, 'show'])->name('bio.short');
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';

View File

@ -6,6 +6,22 @@
* npm install -D @playwright/test && npx playwright install chromium * npm install -D @playwright/test && npx playwright install chromium
* node scripts/console-check.mjs * node scripts/console-check.mjs
*/ */
// Self-re-exec with playwright system deps on Linux if needed
if (process.platform === 'linux' && !process.env.__PW_ENV_OK) {
const { existsSync } = await import('fs');
const depsDir = `${process.env.HOME}/.local/lib/playwright-deps`;
if (existsSync(depsDir)) {
const { spawnSync } = await import('child_process');
const ld = process.env.LD_LIBRARY_PATH ?? '';
const result = spawnSync(process.execPath, process.argv.slice(1), {
env: { ...process.env, LD_LIBRARY_PATH: ld ? `${depsDir}:${ld}` : depsDir, __PW_ENV_OK: '1' },
stdio: 'inherit',
});
process.exit(result.status ?? 1);
}
}
import { chromium } from '@playwright/test'; import { chromium } from '@playwright/test';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { resolve, dirname } from 'path'; import { resolve, dirname } from 'path';
@ -39,7 +55,14 @@ console.log(` ${new Date().toISOString()}`);
console.log(` Base: ${BASE}`); console.log(` Base: ${BASE}`);
console.log(`${'═'.repeat(45)}\n`); console.log(`${'═'.repeat(45)}\n`);
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu',
'--disable-dev-shm-usage', '--in-process-gpu',
'--disable-features=VizDisplayCompositor',
],
});
const ctx = await browser.newContext({ const ctx = await browser.newContext({
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
viewport: { width: 1280, height: 800 }, viewport: { width: 1280, height: 800 },

View File

@ -4,6 +4,22 @@
* *
* Usage: node scripts/crawl-all-routes.mjs * Usage: node scripts/crawl-all-routes.mjs
*/ */
// Self-re-exec with playwright system deps on Linux if needed
if (process.platform === 'linux' && !process.env.__PW_ENV_OK) {
const { existsSync } = await import('fs');
const depsDir = `${process.env.HOME}/.local/lib/playwright-deps`;
if (existsSync(depsDir)) {
const { spawnSync } = await import('child_process');
const ld = process.env.LD_LIBRARY_PATH ?? '';
const result = spawnSync(process.execPath, process.argv.slice(1), {
env: { ...process.env, LD_LIBRARY_PATH: ld ? `${depsDir}:${ld}` : depsDir, __PW_ENV_OK: '1' },
stdio: 'inherit',
});
process.exit(result.status ?? 1);
}
}
import { chromium } from '@playwright/test'; import { chromium } from '@playwright/test';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
@ -73,8 +89,40 @@ const bioSlug = dbQuery(
"SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1" "SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1"
); );
const browser = await chromium.launch({ headless: true }); // Detect if HTTPS crawling works; fall back to HTTP-via-localhost
const HTTP_BASE = 'http://localhost';
const APP_HOST = new URL(BASE).hostname; // e.g. app.nimuli.com
const browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu',
'--disable-dev-shm-usage', '--in-process-gpu',
'--single-process', '--no-zygote',
'--disable-features=VizDisplayCompositor',
],
});
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 900 } }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 900 } });
// Route all HTTPS app.nimuli.com requests to HTTP to avoid TLS renderer crash.
// Vite dev-server assets (@vite, resources/js) → localhost:5173
// Everything else → localhost:80
// Uses route.fetch (Node-side, no TLS) + route.fulfill to serve response to browser.
await ctx.route(`https://${APP_HOST}/**`, async route => {
const u = new URL(route.request().url());
if (u.pathname.startsWith('/@vite') || u.pathname.startsWith('/resources/js') || u.pathname.startsWith('/@id') || u.pathname.startsWith('/@fs')) {
u.protocol = 'http:'; u.hostname = 'localhost'; u.port = '5173';
} else {
u.protocol = 'http:'; u.hostname = 'localhost'; u.port = '';
}
try {
const response = await route.fetch({ url: u.toString() });
await route.fulfill({ response });
} catch {
await route.abort();
}
});
const page = await ctx.newPage(); const page = await ctx.newPage();
// Collect JS errors per-page // Collect JS errors per-page
@ -84,11 +132,11 @@ page.on('pageerror', err => { pageErrors.push(`PageError: ${err.message}`); });
// ── Login ───────────────────────────────────── // ── Login ─────────────────────────────────────
console.log('[ LOGIN ]'); console.log('[ LOGIN ]');
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' }); await page.goto(`${HTTP_BASE}/login`, { waitUntil: 'load' });
await page.fill('input[name=email]', EMAIL); await page.fill('input[name=email]', EMAIL);
await page.fill('input[name=password]', PASS); await page.fill('input[name=password]', PASS);
await page.click('button[type=submit]'); await page.click('button[type=submit]');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('load');
if (page.url().includes('/login')) { if (page.url().includes('/login')) {
console.error(' ✗ Still on login page — wrong credentials or app error'); console.error(' ✗ Still on login page — wrong credentials or app error');
await browser.close(); await browser.close();
@ -101,7 +149,7 @@ const BLADE_ERR_RE = /Undefined variable|ErrorException|Whoops|Class .* not foun
async function check(path, label) { async function check(path, label) {
pageErrors.length = 0; pageErrors.length = 0;
const resp = await page.goto(`${BASE}/${path}`, { waitUntil: 'networkidle' }); const resp = await page.goto(`${HTTP_BASE}/${path}`, { waitUntil: 'load' });
const status = resp?.status() ?? 0; const status = resp?.status() ?? 0;
const body = await page.content(); const body = await page.content();
const bladeErr = BLADE_ERR_RE.test(body) const bladeErr = BLADE_ERR_RE.test(body)

67
scripts/crawl-all.sh Executable file
View File

@ -0,0 +1,67 @@
#!/bin/bash
# Route smoke test: curl + art test (no browser)
set -uo pipefail
cd "$(dirname "$0")/.."
FAIL=0; OK=0
echo "══════════════════════════════════════"
echo " Nimuli — Route Smoke Test"
echo " $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "══════════════════════════════════════"
# ── Run RouteSmokeTest via artisan (no browser, actingAs) ────
echo ""
echo "[ PHP ROUTE SMOKE TESTS ]"
RESULT=$(docker compose exec -T app php artisan test tests/Feature/RouteSmokeTest.php --no-coverage 2>&1)
echo "$RESULT"
if echo "$RESULT" | grep -qE 'FAILED|FAIL|Error'; then
FAILED_COUNT=$(echo "$RESULT" | grep -cE '✗|FAIL|× ' || true)
echo "❌ RouteSmokeTest FAILED ($FAILED_COUNT failures)"
FAIL=$((FAIL+1))
else
PASS_COUNT=$(echo "$RESULT" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' || echo "?")
echo "✅ RouteSmokeTest passed ($PASS_COUNT tests)"
OK=$((OK+1))
fi
# ── Curl check for public / redirects ────────────────────────
echo ""
echo "[ CURL PUBLIC ROUTES ]"
BASE=https://app.nimuli.com
for ROUTE in "" "login" "forgot-password"; do
S=$(curl -sk -o /tmp/b.html -w "%{http_code}" "$BASE/$ROUTE")
if [[ "$S" =~ ^(200|302|301)$ ]] && ! grep -qE 'Undefined|ErrorException|Whoops|Class.*not found|Target class' /tmp/b.html; then
echo "✅ /$ROUTE$S"
OK=$((OK+1))
else
DETAIL=$(grep -oE 'Undefined.{0,60}|ErrorException.{0,60}|Class.*not found.{0,40}' /tmp/b.html | head -1 || true)
echo "❌ /$ROUTE$S $DETAIL"
FAIL=$((FAIL+1))
fi
done
# ── Bio public route (if slug exists) ────────────────────────
BIO_SLUG=$(docker compose exec -T mysql mysql -unimuli -pnimuli nimuli -se \
"SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' AND bp.is_published=1 LIMIT 1" 2>/dev/null | tr -d '[:space:]')
if [[ -n "$BIO_SLUG" ]]; then
S=$(curl -sk -o /tmp/b.html -w "%{http_code}" "$BASE/b/$BIO_SLUG")
if [[ "$S" =~ ^(200|301|302)$ ]] && ! grep -qE 'Undefined|ErrorException|Whoops|Class.*not found' /tmp/b.html; then
echo "✅ /b/$BIO_SLUG$S"
OK=$((OK+1))
else
echo "❌ /b/$BIO_SLUG$S"
FAIL=$((FAIL+1))
fi
else
echo " — no bio pages in DB"
fi
echo ""
echo "══════════════════════════════════════"
echo " OK: $OK FAIL: $FAIL"
echo "══════════════════════════════════════"
[ "$FAIL" -gt 0 ] && exit 1
exit 0

39
scripts/icons-check.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# Verify all icon components used in Blade/PHP files exist in vendor SVG dirs.
# x-heroicon-{variant}-{name} → vendor/blade-heroicons/resources/svg/{variant}-{name}.svg
# x-tabler-{name} → vendor/blade-tabler-icons/resources/svg/{name}.svg
set -euo pipefail
cd "$(dirname "$0")/.."
MISS=0
HEROICON_DIR="vendor/blade-ui-kit/blade-heroicons/resources/svg"
TABLER_DIR="vendor/blade-ui-kit/blade-tabler-icons/resources/svg"
while IFS= read -r ICON; do
if echo "$ICON" | grep -q "^x-heroicon-"; then
# x-heroicon-{o|s|m|c}-{name} → {variant}-{name}.svg
REST="${ICON#x-heroicon-}"
VARIANT="${REST%%-*}"
NAME="${REST#*-}"
FILE="$HEROICON_DIR/${VARIANT}-${NAME}.svg"
elif echo "$ICON" | grep -q "^x-tabler-"; then
NAME="${ICON#x-tabler-}"
FILE="$TABLER_DIR/${NAME}.svg"
else
continue
fi
if [ ! -f "$FILE" ]; then
echo "$ICON (expected: $FILE)"
MISS=$((MISS + 1))
fi
done < <(grep -rEoh 'x-heroicon-[a-z]-[a-z0-9-]+|x-tabler-[a-z0-9-]+' resources/views app 2>/dev/null | sort -u)
if [ "$MISS" -gt 0 ]; then
echo ""
echo "$MISS missing icon(s)"
exit 1
fi
echo "✅ All icons exist"