Proje dosyaları eklendi
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[Mon Aug 10 13:02:44 2026] PHP 8.4.0 Development Server (http://127.0.0.1:8798) started
|
||||
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54324 Accepted
|
||||
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54324 Closing
|
||||
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54326 Accepted
|
||||
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54326 Closing
|
||||
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54327 Accepted
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54327 Closing
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54328 Accepted
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54328 Closing
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54329 Accepted
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54329 Closing
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54330 Accepted
|
||||
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54330 Closing
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
INFO Server running on [http://127.0.0.1:8788].
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
|
||||
2026-08-10 15:41:30 /up .................................................................................. ~ 0.86ms
|
||||
2026-08-10 15:41:30 / .................................................................................... ~ 0.54ms
|
||||
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
APP_NAME=TrafficPumper
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=tr
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=tr_TR
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=trafficpumper
|
||||
DB_USERNAME=trafficpumper
|
||||
DB_PASSWORD=
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
LEGACY_SQLITE_DATABASE=database/database.sqlite
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
SESSION_HTTP_ONLY=true
|
||||
SESSION_SAME_SITE=lax
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=trafficpumper_cache_
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_CACHE_DB=1
|
||||
|
||||
HORIZON_NAME=TrafficPumper
|
||||
HORIZON_DOMAIN=null
|
||||
HORIZON_PATH=horizon
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# SERP çalıştırma özelliği, üretim kimlik bilgileri ve operasyon ayarları onaylanana kadar kapalı kalır.
|
||||
# Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
SERP_CHECKS_ENABLED=false
|
||||
SERP_OPERATION_MODE=disabled
|
||||
SERP_PROVIDER=dataforseo
|
||||
SERP_ALLOW_FAKE_LOCAL=false
|
||||
SERP_QUEUE=serp
|
||||
SERP_DAILY_USER_QUOTA=25
|
||||
SERP_KEYWORD_COOLDOWN_MINUTES=15
|
||||
SERP_MANUAL_RATE_LIMIT=5
|
||||
SERP_MANUAL_RATE_DECAY_SECONDS=60
|
||||
DATAFORSEO_LOGIN=
|
||||
DATAFORSEO_PASSWORD=
|
||||
DATAFORSEO_BASE_URL=https://api.dataforseo.com
|
||||
DATAFORSEO_AUTOMATIC_MODE=standard
|
||||
DATAFORSEO_MANUAL_MODE=live
|
||||
DATAFORSEO_RESULT_DEPTH=10
|
||||
DATAFORSEO_CONNECT_TIMEOUT=10
|
||||
DATAFORSEO_TIMEOUT=45
|
||||
DATAFORSEO_POLL_INITIAL_DELAY=30
|
||||
DATAFORSEO_POLL_INTERVAL=30
|
||||
DATAFORSEO_POLL_MAX_ATTEMPTS=20
|
||||
DATAFORSEO_MAX_TASK_AGE_MINUTES=30
|
||||
SERP_DAILY_BUDGET_USD=1.00
|
||||
SERP_MONTHLY_BUDGET_USD=20.00
|
||||
SERP_BUDGET_WARNING_PERCENT=80
|
||||
SERP_ESTIMATED_LIVE_COST_USD=0.003
|
||||
SERP_ESTIMATED_STANDARD_COST_USD=0.001
|
||||
DATAFORSEO_LOW_BALANCE_USD=10.00
|
||||
DATAFORSEO_CRITICAL_BALANCE_USD=2.00
|
||||
DATAFORSEO_HEALTH_TTL_SECONDS=300
|
||||
SERP_CANARY_KEYWORD=trafficpumper canary
|
||||
SERP_CANARY_COUNTRY=TR
|
||||
SERP_CANARY_LANGUAGE=tr
|
||||
SERP_CANARY_DEVICE=desktop
|
||||
SERP_CANARY_DAILY_LIMIT=1
|
||||
SERP_CIRCUIT_FAILURE_THRESHOLD=5
|
||||
SERP_CIRCUIT_WINDOW_MINUTES=5
|
||||
SERP_CIRCUIT_COOLDOWN_MINUTES=10
|
||||
SERP_STUCK_AFTER_MINUTES=10
|
||||
SERP_MAX_POLL_ATTEMPTS=20
|
||||
SERP_MAX_POLL_AGE_MINUTES=60
|
||||
SERP_RAMP_FIRST_HOUR_LIMIT=5
|
||||
SERP_RAMP_SIX_HOUR_LIMIT=25
|
||||
SERP_RAMP_FIRST_DAY_LIMIT=100
|
||||
EARN_DEVICE_HASH_KEY=
|
||||
TEST_TASKS_CUSTOMER_EMAIL=
|
||||
EARN_MAX_TOTAL_TOKENS=5
|
||||
EARN_DEVICE_BIND_RATE_LIMIT=5
|
||||
EARN_TASKS_RATE_LIMIT=30
|
||||
EARN_TASK_RESULTS_RATE_LIMIT=30
|
||||
EARN_TASK_RESULT_MAX_ERROR_LENGTH=500
|
||||
EARN_FAILED_DEVICE_RATE_LIMIT=3
|
||||
EARN_API_ACCESS_LOG_RETENTION_DAYS=90
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
[Sun Aug 9 19:45:09 2026] PHP 8.4.0 Development Server (http://127.0.0.1:8797) started
|
||||
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33023 Accepted
|
||||
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33023 Closing
|
||||
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33024 Accepted
|
||||
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33024 Closing
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
INFO Server running on [http://127.0.0.1:8796].
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
@@ -0,0 +1,108 @@
|
||||
# TrafficPumper — AŞAMA 1
|
||||
|
||||
Laravel 13 tabanlı uygulama temeli; Filament 5, Livewire, Sanctum, Horizon,
|
||||
Redis/Predis, Bootstrap 5.3 ve Vite ile hazırlanmıştır.
|
||||
|
||||
Tema tercihi `trafficpumper.theme` anahtarında `light`, `dark` veya `system`
|
||||
olarak tutulur. `resources/js/theme-manager.js` hem Blade yerleşiminde ilk boyadan
|
||||
önce hem de Filament head hook'unda kullanılır. Filament'in kendi seçicisi ortak
|
||||
tercihle senkronize edilir.
|
||||
|
||||
Yerel Windows ortamında Horizon worker çalıştırmak için gerekli `pcntl` ve `posix`
|
||||
PHP uzantıları bulunmaz. Horizon, Linux üretim ortamı için kurulu ve yapılandırılmıştır.
|
||||
|
||||
```powershell
|
||||
composer install
|
||||
npm install
|
||||
php artisan test
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
API iş rotaları ileride `routes/api/v1.php` altında ve `/api/v1/...` önekiyle
|
||||
eklenecektir.
|
||||
|
||||
## İlk süper admin
|
||||
|
||||
Gerçek parola seed dosyalarına yazılmaz. İlk yetkili hesabı interaktif ve gizli
|
||||
parola girişiyle oluşturun:
|
||||
|
||||
```powershell
|
||||
php artisan trafficpumper:make-super-admin
|
||||
```
|
||||
|
||||
Public `/register` yalnızca `customer` rolü oluşturur. `/dashboard` aktif ve
|
||||
e-postası doğrulanmış oturum; `/admin` ayrıca `admin.access` yetkisi ve Filament
|
||||
`canAccessPanel()` kontrolü gerektirir.
|
||||
|
||||
## AŞAMA 3 — Proje / Website yönetimi
|
||||
|
||||
Müşteri proje ekranları /projects altında çalışır. Projeler dahili integer anahtarın
|
||||
yanında URL'lerde kullanılan ULID taşır ve user_id + domain birleşik benzersizlik
|
||||
kuralıyla korunur. Yalnızca HTTP/HTTPS kabul eden merkezi URL normalizasyonu
|
||||
www. önekini kaldırır; localhost ve IP adreslerini reddeder. Kayıt sırasında
|
||||
herhangi bir ağ isteği yapılmaz.
|
||||
|
||||
Müşteri işlemleri sorgu seviyesinde oturum sahibine sınırlandırılır ve ayrıca
|
||||
ProjectPolicy ile yetkilendirilir. Başka müşteriye ait bir ULID için tutarlı 404
|
||||
döner. Silme yerine açık arşivleme/geri yükleme uçları kullanılır. Backoffice
|
||||
liste/detay görünümü /admin/projects rotasındadır; sahip değiştirme ve hard delete
|
||||
sunulmaz.
|
||||
|
||||
Doğrulama komutları: php artisan migrate, php artisan test, npm test ve npm run build.
|
||||
## AŞAMA 4 — Keyword ve SERP veri altyapısı
|
||||
|
||||
Project → Keyword ilişkisi ULID public kimlikleri ve User → Project → Keyword
|
||||
sahiplik zinciriyle kuruldu. Hedef kimliği normalize edilmiş ifade, arama motoru,
|
||||
ülke, dil ve cihaz birleşimidir; aynı Project içinde DB seviyesinde benzersizdir.
|
||||
Whitespace tekilleştirilir, locale bağımsız küçük harf dönüşümü uygulanır ve Türkçe
|
||||
karakterler korunur. Arşivlenen hedef kimliği tutulur ve yeni kayıt yerine restore
|
||||
edilir. Customer nested rotaları sorgu seviyesinde sahipliğe, Filament görünümü ise
|
||||
keywords.view / keywords.manage izinlerine bağlıdır.
|
||||
|
||||
serp_checks hedef snapshotlarını, serp_results organik sonuç satırlarını saklayacak
|
||||
veri altyapısıdır. Bu aşamada dış arama motoru sorgusu, scraping, provider, queue,
|
||||
scheduler veya customer SERP endpointi eklenmemiştir.
|
||||
## AŞAMA 5 — Provider-neutral SERP çalıştırma
|
||||
|
||||
SERP sağlayıcıları SerpProvider sözleşmesi üzerinden registry tarafından çözülür;
|
||||
domain kodu provider SDK veya response biçimi bilmez. SerpQuery, SerpResponse ve
|
||||
SerpResultData immutable sınırı oluşturur. Fake provider yalnız testing veya açıkça
|
||||
izin verilmiş local ortamda çalışır; production ortamında sonuç üretemez. Bu aşamada
|
||||
production provider ve dış SERP HTTP isteği yoktur.
|
||||
|
||||
Kontroller serp kuyruğunda RunSerpCheck job'ıyla çalışır: 3 deneme, 60 saniye
|
||||
timeout ve 10/60/300 saniye backoff. Transient timeout/rate-limit hataları yeniden
|
||||
denenir; kalıcı hatalar güvenli kodla failed olur. Keyword başına atomik cache lock,
|
||||
DB active-slot unique constraint ve job lock birlikte duplicate/eşzamanlı çalışmayı
|
||||
engeller. Provider çağrısı transaction dışında, sonuç tamamlama transaction içindedir.
|
||||
|
||||
Manuel kontroller kullanıcı rate-limit'i, 15 dakika keyword cooldown'u, günlük 25
|
||||
kontrol kotası ve pending/running engeline tabidir. Otomatik takip daily/weekly olup
|
||||
UTC next_check_at kullanır. Scheduler her dakika due kayıtları chunk ile kuyruğa
|
||||
alır, overlap ve tek-sunucu kilidi kullanır. Domain eşleşmesi exact domain veya
|
||||
gerçek subdomain kabul eder; www eşdeğerdir, metinsel sahte suffix eşleşmez.
|
||||
|
||||
Production provider eklemek için SerpProvider adaptörü yazılır, service container
|
||||
registry'sine kaydedilir ve SERP_PROVIDER değeri adaptör anahtarına ayarlanır.
|
||||
|
||||
## AŞAMA 6 — DataForSEO Google Organic adaptörü
|
||||
|
||||
Production adaptörü `dataforseo` anahtarıyla kayıtlıdır. Manuel kontroller Google Organic Live Advanced, zamanlanmış daily/weekly kontroller Standard `task_post` ve gecikmeli `task_get/advanced` poll akışını kullanır. Payload ülke, dil ve cihaz allowlist değerlerinden sunucu tarafında üretilir; `depth` daima 10'dur. Organik sonuç sırası DataForSEO `rank_group` alanından normalize edilir; sağlayıcıya özgü JSON domain modeline veya veritabanına yazılmaz.
|
||||
|
||||
HTTP istemcisi yalnız `https://api.dataforseo.com` taban adresini, Basic Auth kimlik bilgilerini, TLS doğrulamasını, bağlantı/istek timeout'larını ve redirectsiz çağrıları kullanır. HTTP status ile DataForSEO `status_code` zarfı ayrı doğrulanır. Kimlik bilgileri yalnız environment/config üzerinden alınır ve loglanmaz.
|
||||
|
||||
Kurulum güvenli varsayılanla gelir: `SERP_CHECKS_ENABLED=false`. Production kimlik bilgileri girilse bile operasyonel onay verilmeden bu değer açılmamalıdır. Gerçek API çağrısı testlerde yapılmaz; `Http::preventStrayRequests()` ve `Http::fake()` kullanılır.
|
||||
|
||||
|
||||
## ASAMA 7 — Kontrollu production aktivasyonu
|
||||
|
||||
SERP operasyonu disabled, validation_only, canary, enabled ve DB kill-switch ile emergency_stopped durumlarini ayirir. Normal ucretli akis icin hem SERP_CHECKS_ENABLED=true hem SERP_OPERATION_MODE=enabled gerekir; bilinmeyen durum fail-closed davranir.
|
||||
|
||||
Maliyetler mikro-USD ledgerda UTC gunluk/aylik atomik rezervasyon ve gercek maliyetle tekil reconciliation kullanir. Kill switch ve circuit breaker yeni Live/submit islemlerini engeller, mevcut Standard task polling sonucunu kurtarir. Remote health ve canary execute calistirilmamistir.
|
||||
|
||||
Filament /admin/serp-operations support/admin icin salt okunur, emergency stop/clear yalniz super_admin icindir. Ayrintilar: docs/production-serp-runbook.md.
|
||||
|
||||
## AŞAMA 8 — Güvenli production hazırlığı
|
||||
|
||||
Dış HTTP yapmayan production preflight ve 24 saatlik scrubbed operasyon snapshot komutları ile atomik 5/25/100 ramp-up kapısı eklendi. Başlangıç ve mevcut durum güvenli biçimde `disabled` kalır; remote health, tek canary ve limited production birbirinden ayrı açık onay kapılarıdır. Deployment ve recovery ayrıntıları `docs/production-serp-runbook.md` içindedir.
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SerpOperationAudit;
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Serp\Operations\BudgetManager;
|
||||
use App\Serp\Operations\CircuitBreaker;
|
||||
use App\Serp\Operations\OperationAuditLogger;
|
||||
use App\Serp\Operations\OperationBlockedException;
|
||||
use App\Serp\SerpProviderRegistry;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class DataForSeoCanaryCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:dataforseo:canary {--execute : Tek ücretli canary isteğini bilinçli olarak çalıştır} {--json}';
|
||||
|
||||
protected $description = 'DataForSEO canary kontrolünü varsayılan olarak dry-run yapar';
|
||||
|
||||
public function handle(ActivationManager $activation, BudgetManager $budget, CircuitBreaker $circuit, SerpProviderRegistry $providers, OperationAuditLogger $audit): int
|
||||
{
|
||||
$base = ['mode' => $activation->state()->value, 'execute' => (bool) $this->option('execute'), 'depth' => 10, 'provider' => 'dataforseo'];
|
||||
if (! $this->option('execute')) {
|
||||
$base['status'] = 'dry_run';
|
||||
$this->output($base);
|
||||
|
||||
return self::SUCCESS;
|
||||
}try {
|
||||
$activation->assertPaid(true);
|
||||
$circuit->assertClosed();
|
||||
$count = SerpOperationAudit::where('action', 'canary_executed')->where('created_at', '>=', now('UTC')->startOfDay())->count();
|
||||
if ($count >= (int) config('serp.canary.daily_limit')) {
|
||||
throw new OperationBlockedException('canary_limit');
|
||||
}$key = 'canary:'.now('UTC')->format('Y-m-d');
|
||||
$budget->reserve($key, 'live');
|
||||
$q = new SerpQuery((string) config('serp.canary.keyword'), 'google', (string) config('serp.canary.country'), (string) config('serp.canary.language'), (string) config('serp.canary.device'), 10);
|
||||
$started = microtime(true);
|
||||
$r = $providers->resolve('dataforseo')->search($q);
|
||||
$budget->reconcile($key, $r->cost, $r->referenceId);
|
||||
$circuit->success();
|
||||
$audit->record('canary_executed', ['provider' => 'dataforseo', 'mode' => 'live', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'result_count' => count($r->results)]);
|
||||
$base += ['status' => 'completed', 'result_count' => count($r->results), 'duration_ms' => (int) ((microtime(true) - $started) * 1000)];
|
||||
$this->output($base);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$base += ['status' => 'blocked_or_failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'unexpected'];
|
||||
$this->output($base);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function output(array $data): void
|
||||
{
|
||||
$this->option('json') ? $this->line(json_encode($data)) : $this->info('Canary: '.$data['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use App\Serp\Operations\DataForSeoHealthService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class DataForSeoHealthCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:dataforseo:health {--remote : Açık izinle ücretsiz hesap/bakiye doğrulaması} {--json}';
|
||||
|
||||
protected $description = 'DataForSEO yapılandırmasını güvenli biçimde doğrular';
|
||||
|
||||
public function handle(DataForSeoHealthService $health): int
|
||||
{
|
||||
try {
|
||||
$data = $health->validateConfig();
|
||||
if ($this->option('remote')) {
|
||||
$r = $health->remote();
|
||||
$data += ['remote' => 'ok', 'balance_micros' => $r->balanceMicros, 'status_code' => $r->statusCode, 'checked_at' => $r->checkedAt->format(DATE_ATOM)];
|
||||
} else {
|
||||
$data += ['remote' => 'not_requested'];
|
||||
}$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->info('DataForSEO health: OK (uzak çağrı: '.$data['remote'].')');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$safe = ['status' => 'failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'configuration_invalid'];
|
||||
$this->option('json') ? $this->line(json_encode($safe)) : $this->error('DataForSEO health doğrulanamadı.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\KeywordStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\Keyword;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Services\NextCheckAtCalculator;
|
||||
use App\Services\SerpCheckCreator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class DispatchDueSerpChecks extends Command
|
||||
{
|
||||
protected $signature = 'serp:dispatch-due';
|
||||
|
||||
protected $description = 'Due SERP kontrollerini kuyruğa alır';
|
||||
|
||||
public function handle(SerpCheckCreator $creator, NextCheckAtCalculator $next, ActivationManager $activation): int
|
||||
{
|
||||
if (! config('serp.enabled') || ! config('serp.provider') || (config('serp.provider') === 'dataforseo' && ! $activation->allowsNormal())) {
|
||||
return self::SUCCESS;
|
||||
}Keyword::query()->with('project.user')->where('tracking_enabled', true)->where('status', KeywordStatus::Active)->where('next_check_at', '<=', now())->whereHas('project.user', fn ($q) => $q->where('status', UserStatus::Active))->whereDoesntHave('serpChecks', fn ($q) => $q->whereIn('status', ['pending', 'running']))->chunkById(100, function ($keywords) use ($creator, $next) {
|
||||
foreach ($keywords as $k) {
|
||||
try {
|
||||
$creator->create($k->project->user, $k, true, 'scheduled');
|
||||
$k->forceFill(['last_queued_at' => now(), 'next_check_at' => $next->calculate($k->check_frequency)])->save();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class MakeSuperAdmin extends Command
|
||||
{
|
||||
protected $signature = 'trafficpumper:make-super-admin';
|
||||
|
||||
protected $description = 'İlk TrafficPumper süper admin hesabını güvenli şekilde oluşturur';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$name = (string) $this->ask('Ad Soyad');
|
||||
$email = mb_strtolower((string) $this->ask('E-posta'));
|
||||
if (User::where('email', $email)->exists()) {
|
||||
$this->error('Bu e-posta zaten kullanımda. Mevcut hesap otomatik olarak yükseltilmedi.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$password = (string) $this->secret('Şifre');
|
||||
$confirmation = (string) $this->secret('Şifre tekrar');
|
||||
$validator = Validator::make(compact('name', 'email', 'password') + ['password_confirmation' => $confirmation], [
|
||||
'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$user = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
|
||||
$user->forceFill(['email_verified_at' => now()])->saveQuietly();
|
||||
$user->roles()->attach(Role::where('name', 'super_admin')->sole());
|
||||
$this->info('Süper admin hesabı oluşturuldu.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class MigrateSqliteToMariaDb extends Command
|
||||
{
|
||||
protected $signature = 'trafficpumper:migrate-sqlite-to-mariadb {--execute} {--chunk=500} {--include-operational} {--replace-migrated-seeds}';
|
||||
|
||||
protected $description = 'Inspect or copy legacy SQLite business data to an already migrated MariaDB database';
|
||||
|
||||
private const OPERATIONAL = ['cache', 'cache_locks', 'sessions', 'jobs', 'job_batches', 'failed_jobs'];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $alreadyPresent = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $replaceSeeds = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$source = DB::connection('sqlite_legacy');
|
||||
$target = DB::connection('mysql');
|
||||
$this->assertConnections($source, $target);
|
||||
$tables = $this->orderedTables($source);
|
||||
$this->assertTargetSchema($target, $tables);
|
||||
$this->reportPlan($source, $target, $tables);
|
||||
if (! $this->option('execute')) {
|
||||
$this->info('Dry-run completed. No rows were written. Use --execute to transfer.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->assertEmptyTarget($target, $tables);
|
||||
$this->copy($source, $target, $tables);
|
||||
$this->verify($source, $target, $tables);
|
||||
$this->info('Transfer and integrity verification completed successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function assertConnections(Connection $source, Connection $target): void
|
||||
{
|
||||
if ($source->getDriverName() !== 'sqlite') {
|
||||
throw new RuntimeException('sqlite_legacy must use SQLite.');
|
||||
}
|
||||
if (! in_array($target->getDriverName(), ['mysql', 'mariadb'], true)) {
|
||||
throw new RuntimeException('The mysql connection must point to MariaDB/MySQL.');
|
||||
}
|
||||
$path = (string) config('database.connections.sqlite_legacy.database');
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('The legacy SQLite database is missing or unreadable.');
|
||||
}
|
||||
$server = $target->selectOne('select version() version, @@character_set_database charset, @@collation_database collation');
|
||||
if ($server->charset !== 'utf8mb4' || $server->collation !== 'utf8mb4_unicode_ci') {
|
||||
throw new RuntimeException('Target database must use utf8mb4 and utf8mb4_unicode_ci.');
|
||||
}
|
||||
$this->info("Target server: {$server->version}; {$server->charset}/{$server->collation}");
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function orderedTables(Connection $source): array
|
||||
{
|
||||
$excluded = ['migrations', ...($this->option('include-operational') ? [] : self::OPERATIONAL)];
|
||||
$tables = collect(Schema::connection($source->getName())->getTables())
|
||||
->map(fn (array $table): string => (string) ($table['name'] ?? ''))
|
||||
->filter(fn (string $table): bool => $table !== '' && ! in_array($table, $excluded, true))->values()->all();
|
||||
$pending = array_fill_keys($tables, true);
|
||||
$ordered = [];
|
||||
while ($pending !== []) {
|
||||
$progress = false;
|
||||
foreach (array_keys($pending) as $table) {
|
||||
$dependencies = collect(Schema::connection($source->getName())->getForeignKeys($table))
|
||||
->map(fn (array $key): string => (string) ($key['foreign_table'] ?? ''))
|
||||
->filter(fn (string $dependency): bool => isset($pending[$dependency]) && $dependency !== $table);
|
||||
if ($dependencies->isNotEmpty()) {
|
||||
continue;
|
||||
}
|
||||
$ordered[] = $table;
|
||||
unset($pending[$table]);
|
||||
$progress = true;
|
||||
}
|
||||
if (! $progress) {
|
||||
throw new RuntimeException('Unresolved foreign-key dependencies: '.implode(', ', array_keys($pending)));
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function assertTargetSchema(Connection $target, array $tables): void
|
||||
{
|
||||
$missing = collect($tables)->reject(fn (string $table): bool => Schema::connection($target->getName())->hasTable($table));
|
||||
if ($missing->isNotEmpty()) {
|
||||
throw new RuntimeException('Run target migrations first. Missing tables: '.$missing->implode(', '));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function reportPlan(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
$this->table(['Table', 'SQLite', 'MariaDB'], collect($tables)->map(fn (string $table): array => [$table, $source->table($table)->count(), $target->table($table)->count()])->all());
|
||||
if (! $this->option('include-operational')) {
|
||||
$this->warn('Operational tables are excluded; database sessions will require users to sign in again.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function assertEmptyTarget(Connection $target, array $tables): void
|
||||
{
|
||||
$source = DB::connection('sqlite_legacy');
|
||||
foreach ($tables as $table) {
|
||||
if (! $target->table($table)->exists()) {
|
||||
continue;
|
||||
}
|
||||
if ($source->table($table)->count() === $target->table($table)->count()
|
||||
&& hash_equals($this->tableChecksum($source, $table), $this->tableChecksum($target, $table))) {
|
||||
$this->alreadyPresent[$table] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$primaryColumns = (array) ($primary['columns'] ?? []);
|
||||
if ($this->option('replace-migrated-seeds') && $primaryColumns !== []
|
||||
&& $source->table($table)->count() === $target->table($table)->count()
|
||||
&& hash_equals($this->keyChecksum($source, $table, $primaryColumns), $this->keyChecksum($target, $table, $primaryColumns))) {
|
||||
$this->replaceSeeds[$table] = $primaryColumns;
|
||||
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException("Target table {$table} is non-empty and does not exactly match the source.");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function copy(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
$chunk = max(1, min(5000, (int) $this->option('chunk')));
|
||||
foreach ($tables as $table) {
|
||||
if (isset($this->alreadyPresent[$table])) {
|
||||
$this->line("Kept identical migrated seed data in {$table}");
|
||||
|
||||
continue;
|
||||
}
|
||||
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name')->all();
|
||||
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$order = ((array) ($primary['columns'] ?? []))[0] ?? $columns[0] ?? null;
|
||||
if ($order === null) {
|
||||
continue;
|
||||
}
|
||||
$target->transaction(function () use ($source, $target, $table, $order, $chunk): void {
|
||||
$source->table($table)->orderBy($order)->chunk($chunk, function ($rows) use ($target, $table): void {
|
||||
$data = $rows->map(fn (object $row): array => (array) $row)->all();
|
||||
if ($data !== []) {
|
||||
if (isset($this->replaceSeeds[$table])) {
|
||||
$target->table($table)->upsert($data, $this->replaceSeeds[$table]);
|
||||
} else {
|
||||
$target->table($table)->insert($data);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
$this->line("Copied {$table}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function verify(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
foreach ($tables as $table) {
|
||||
if ($source->table($table)->count() !== $target->table($table)->count()) {
|
||||
throw new RuntimeException("Row-count mismatch for {$table}.");
|
||||
}
|
||||
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name');
|
||||
foreach ($columns->filter(fn (string $column): bool => preg_match('/(^|_)(password|token|ulid|uuid)(_hash)?$/', $column) === 1) as $column) {
|
||||
if (! hash_equals($this->checksum($source, $table, $column), $this->checksum($target, $table, $column))) {
|
||||
throw new RuntimeException("Sensitive checksum mismatch for {$table}.{$column}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($tables as $table) {
|
||||
foreach (Schema::connection($target->getName())->getForeignKeys($table) as $key) {
|
||||
$local = (array) ($key['columns'] ?? []);
|
||||
$foreign = (array) ($key['foreign_columns'] ?? []);
|
||||
$parent = (string) ($key['foreign_table'] ?? '');
|
||||
if (count($local) !== 1 || count($foreign) !== 1 || $parent === '') {
|
||||
continue;
|
||||
}
|
||||
$orphans = $target->table("{$table} as child")
|
||||
->leftJoin("{$parent} as parent", "child.{$local[0]}", '=', "parent.{$foreign[0]}")
|
||||
->whereNotNull("child.{$local[0]}")->whereNull("parent.{$foreign[0]}")->count();
|
||||
if ($orphans > 0) {
|
||||
throw new RuntimeException("Foreign-key orphans found in {$table}.{$local[0]}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checksum(Connection $connection, string $table, string $column): string
|
||||
{
|
||||
$hash = hash_init('sha256');
|
||||
$connection->table($table)->orderBy($column)->select($column)->chunk(1000, function ($rows) use ($hash, $column): void {
|
||||
foreach ($rows as $row) {
|
||||
$value = (string) $row->{$column};
|
||||
hash_update($hash, strlen($value).':'.$value."\n");
|
||||
}
|
||||
});
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
|
||||
private function tableChecksum(Connection $connection, string $table): string
|
||||
{
|
||||
$columns = collect(Schema::connection($connection->getName())->getColumns($table))->pluck('name')->all();
|
||||
$primary = collect(Schema::connection($connection->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$order = (array) ($primary['columns'] ?? $columns);
|
||||
$hash = hash_init('sha256');
|
||||
$query = $connection->table($table)->select($columns);
|
||||
foreach ($order as $column) {
|
||||
$query->orderBy($column);
|
||||
}
|
||||
$query->chunk(1000, function ($rows) use ($hash, $columns): void {
|
||||
foreach ($rows as $row) {
|
||||
foreach ($columns as $column) {
|
||||
$value = $row->{$column};
|
||||
hash_update($hash, $value === null ? '-1:' : strlen((string) $value).':'.(string) $value);
|
||||
}
|
||||
hash_update($hash, "\n");
|
||||
}
|
||||
});
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
private function keyChecksum(Connection $connection, string $table, array $columns): string
|
||||
{
|
||||
$hash = hash_init('sha256');
|
||||
$query = $connection->table($table)->select($columns);
|
||||
foreach ($columns as $column) {
|
||||
$query->orderBy($column);
|
||||
}
|
||||
foreach ($query->get() as $row) {
|
||||
foreach ($columns as $column) {
|
||||
hash_update($hash, strlen((string) $row->{$column}).':'.(string) $row->{$column});
|
||||
}
|
||||
hash_update($hash, "\n");
|
||||
}
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PollSerpCheck;
|
||||
use App\Models\SerpCheck;
|
||||
use App\Serp\Operations\OperationAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReconcileSerpChecksCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:reconcile {--execute : Düzeltmeleri uygula}';
|
||||
|
||||
protected $description = 'Takılı SERP kontrollerini güvenli biçimde uzlaştırır';
|
||||
|
||||
public function handle(OperationAuditLogger $audit): int
|
||||
{
|
||||
$count = 0;
|
||||
SerpCheck::query()->whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->chunkById(100, function ($checks) use (&$count, $audit) {
|
||||
foreach ($checks as $c) {
|
||||
$count++;
|
||||
if (! $this->option('execute')) {
|
||||
continue;
|
||||
}if ($c->execution_mode === 'standard' && $c->provider_reference) {
|
||||
PollSerpCheck::dispatch($c->ulid)->onQueue(config('serp.queue'));
|
||||
$audit->record('reconciliation_dispatched', ['check_ulid' => $c->ulid, 'mode' => 'standard']);
|
||||
} elseif (! $c->provider_reference) {
|
||||
$c->fail('stuck_without_task', 'SERP kontrolü zamanında tamamlanamadı.');
|
||||
$audit->record('reconciliation_failed_stuck', ['check_ulid' => $c->ulid, 'error_category' => 'poll_exhausted']);
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->info(($this->option('execute') ? 'İşlenen' : 'Bulunan').' kayıt: '.$count);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Serp\Operations\SerpOperationsMetrics;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SerpOperationsSnapshotCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:operations:snapshot {--window=24h} {--json}';
|
||||
|
||||
protected $description = 'Scrub edilmiş SERP operasyon özetini gösterir';
|
||||
|
||||
public function handle(SerpOperationsMetrics $metrics): int
|
||||
{
|
||||
if ($this->option('window') !== '24h') {
|
||||
$this->error('Desteklenen pencere: 24h');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$data = $metrics->summary() + ['window' => '24h', 'generated_at' => now('UTC')->toAtomString()];
|
||||
$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->table(['Alan', 'Değer'], collect($data)->except('budget')->map(fn ($v, $k) => [$k, is_scalar($v) || $v === null ? (string) $v : json_encode($v)])->values()->all());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SerpCheck;
|
||||
use App\Models\SerpOperationState;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Serp\Operations\CircuitBreaker;
|
||||
use App\Serp\Operations\DataForSeoHealthService;
|
||||
use App\Serp\Operations\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
class SerpProductionPreflightCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:production:preflight {--json}';
|
||||
|
||||
protected $description = 'Production SERP geçiş kapılarını dış HTTP olmadan doğrular';
|
||||
|
||||
public function handle(ActivationManager $activation, DataForSeoHealthService $health, CircuitBreaker $circuit): int
|
||||
{
|
||||
$gates = [];
|
||||
$gate = function (string $name, bool $pass, mixed $value = null, bool $critical = true) use (&$gates) {
|
||||
$gates[$name] = ['status' => $pass ? 'pass' : 'fail', 'critical' => $critical, 'value' => $value];
|
||||
};
|
||||
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
|
||||
$gate('environment', app()->environment('production'), app()->environment());
|
||||
$gate('debug', ! config('app.debug'), config('app.debug') ? 'enabled' : 'disabled');
|
||||
$gate('provider', config('serp.provider') === 'dataforseo', config('serp.provider'));
|
||||
$state = $activation->state();
|
||||
$consistent = (! config('serp.enabled') && in_array($state->value, ['disabled', 'validation_only', 'canary'], true)) || (config('serp.enabled') && in_array($state->value, ['canary', 'enabled', 'emergency_stopped'], true));
|
||||
$gate('activation_consistency', $consistent, ['enabled' => (bool) config('serp.enabled'), 'mode' => $state->value]);
|
||||
$gate('credentials', $configured, $configured ? 'configured' : 'not_configured');
|
||||
try {
|
||||
$local = $health->validateConfig();
|
||||
$gate('provider_security', true, ['host' => $local['host'], 'tls' => $local['tls']]);
|
||||
} catch (Throwable) {
|
||||
$gate('provider_security', false, 'invalid');
|
||||
}$pending = $this->pendingMigrations();
|
||||
$gate('migrations', $pending === 0, ['pending' => $pending]);
|
||||
try {
|
||||
$lock = Cache::lock('serp:preflight-lock', 5);
|
||||
$acquired = $lock->get();
|
||||
if ($acquired) {
|
||||
$lock->release();
|
||||
}$gate('cache_lock', $acquired, config('cache.default'));
|
||||
} catch (Throwable) {
|
||||
$gate('cache_lock', false, 'unavailable');
|
||||
}$gate('queue', in_array(config('queue.default'), ['redis', 'database'], true), ['connection' => config('queue.default'), 'serp_queue' => config('serp.queue')]);
|
||||
$console = file_get_contents(base_path('routes/console.php'));
|
||||
$gate('scheduler', str_contains($console, 'serp:dispatch-due') && str_contains($console, 'serp:reconcile'), ['defined' => true]);
|
||||
$stateRow = SerpOperationState::find(1);
|
||||
$gate('kill_switch', ! $stateRow?->emergency_stopped_at, $stateRow?->emergency_stopped_at ? 'active' : 'clear');
|
||||
$gate('circuit', ! $circuit->open(), $circuit->open() ? 'open' : 'closed');
|
||||
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
|
||||
try {
|
||||
$gate($key, Money::micros((string) config('serp.'.$key)) > 0, (string) config('serp.'.$key));
|
||||
} catch (Throwable) {
|
||||
$gate($key, false, 'invalid');
|
||||
}
|
||||
}$gate('depth', config('serp.dataforseo.result_depth') === 10, config('serp.dataforseo.result_depth'));
|
||||
$gate('provider_modes', config('serp.manual_mode') === 'live' && config('serp.automatic_mode') === 'standard', ['manual' => config('serp.manual_mode'), 'automatic' => config('serp.automatic_mode')]);
|
||||
$summary = ['status' => collect($gates)->contains(fn ($g) => $g['critical'] && $g['status'] === 'fail') ? 'failed' : 'passed', 'remote' => 'not_requested', 'gates' => $gates, 'operations' => ['last_health_at' => $stateRow?->last_health_at, 'last_provider_success_at' => $stateRow?->last_provider_success_at, 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'pending' => SerpCheck::where('status', 'pending')->count(), 'running' => SerpCheck::where('status', 'running')->count()]];
|
||||
$this->option('json') ? $this->line(json_encode($summary, JSON_THROW_ON_ERROR)) : $this->line('Preflight: '.$summary['status']);
|
||||
|
||||
return $summary['status'] === 'passed' ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function pendingMigrations(): int
|
||||
{
|
||||
if (! Schema::hasTable('migrations')) {
|
||||
return count(glob(database_path('migrations/*.php')));
|
||||
}$ran = DB::table('migrations')->pluck('migration')->all();
|
||||
|
||||
return count(array_filter(glob(database_path('migrations/*.php')), fn ($f) => ! in_array(pathinfo($f, PATHINFO_FILENAME), $ran, true)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Enums;
|
||||
enum CheckFrequency:string {case Daily='daily';case Weekly='weekly';public function label():string{return match($this){self::Daily=>__('states.daily'),self::Weekly=>__('states.weekly')};}}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EarnApiTokenStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Inactive = 'inactive';
|
||||
case Expired = 'expired';
|
||||
case Revoked = 'revoked';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EarnLegalDocumentType: string
|
||||
{
|
||||
case Terms = 'terms';
|
||||
case PrivacyNotice = 'privacy_notice';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EarnMemberStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Active = 'active';
|
||||
case Suspended = 'suspended';
|
||||
case Closed = 'closed';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Enums;
|
||||
enum KeywordDevice:string { case Desktop='desktop'; case Mobile='mobile'; public function label():string{return match($this){self::Desktop=>__('states.desktop'),self::Mobile=>__('states.mobile')};} public static function options():array{return collect(self::cases())->mapWithKeys(fn($v)=>[$v->value=>$v->label()])->all();} }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Enums;
|
||||
enum KeywordStatus:string { case Active='active'; case Inactive='inactive'; case Archived='archived'; public function label():string{return match($this){self::Active=>__('states.active'),self::Inactive=>__('states.inactive'),self::Archived=>__('states.archived')};} }
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProjectStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Archived = 'archived';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => __('states.active'),
|
||||
self::Archived => __('states.archived'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RoleName: string
|
||||
{
|
||||
case SuperAdmin = 'super_admin';
|
||||
case Admin = 'admin';
|
||||
case Support = 'support';
|
||||
case Customer = 'customer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SuperAdmin => 'Süper Admin', self::Admin => 'Admin',
|
||||
self::Support => 'Destek', self::Customer => 'Müşteri',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Enums;
|
||||
enum SearchEngine:string { case Google='google'; case Bing='bing'; public function label():string{return match($this){self::Google=>'Google',self::Bing=>'Bing'};} public static function options():array{return collect(self::cases())->mapWithKeys(fn($v)=>[$v->value=>$v->label()])->all();} }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Enums;
|
||||
enum SerpCheckStatus:string {case Pending='pending';case Running='running';case Completed='completed';case Failed='failed';public function label():string{return match($this){self::Pending=>__('states.pending'),self::Running=>__('states.running'),self::Completed=>__('states.completed'),self::Failed=>__('states.failed')};}}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Suspended = 'suspended';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => __('states.active'),
|
||||
self::Suspended => __('states.suspended'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\Login;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminLogin extends Login
|
||||
{
|
||||
protected function throwFailureValidationException(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['data.email' => __('auth.portal_denied')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Serp\Operations\KillSwitch;
|
||||
use App\Serp\Operations\SerpOperationsMetrics;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
|
||||
class SerpOperations extends Page
|
||||
{
|
||||
protected string $view = 'filament.pages.serp-operations';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('ui.serp_operations');
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('ui.serp_operations');
|
||||
}
|
||||
|
||||
public array $summary = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->hasPermission('serp_operations.view') ?? false;
|
||||
}
|
||||
|
||||
public function mount(SerpOperationsMetrics $metrics): void
|
||||
{
|
||||
$this->summary = $metrics->summary();
|
||||
}
|
||||
|
||||
public function emergencyStop(KillSwitch $kill): void
|
||||
{
|
||||
$this->authorizeManage();
|
||||
$kill->activate('manual_emergency_stop', auth()->user());
|
||||
$this->summary = app(SerpOperationsMetrics::class)->summary();
|
||||
Notification::make()->title(__('operations.stopped'))->warning()->send();
|
||||
}
|
||||
|
||||
public function clearEmergencyStop(KillSwitch $kill): void
|
||||
{
|
||||
$this->authorizeManage();
|
||||
$kill->clear(auth()->user(), 'manual_clear');
|
||||
$this->summary = app(SerpOperationsMetrics::class)->summary();
|
||||
Notification::make()->title(__('operations.resumed'))->success()->send();
|
||||
}
|
||||
|
||||
private function authorizeManage(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->hasRole('super_admin') && auth()->user()?->hasPermission('serp_operations.emergency_stop'), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Keywords;
|
||||
|
||||
use App\Filament\Resources\Keywords\Pages\ListKeywords;
|
||||
use App\Filament\Resources\Keywords\Pages\ViewKeyword;
|
||||
use App\Filament\Resources\Keywords\Schemas\KeywordInfolist;
|
||||
use App\Filament\Resources\Keywords\Tables\KeywordsTable;
|
||||
use App\Models\Keyword;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class KeywordResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Keyword::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlass;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('ui.keywords');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('resources.keyword');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('ui.keywords');
|
||||
}
|
||||
|
||||
public static function infolist(Schema $s): Schema
|
||||
{
|
||||
return KeywordInfolist::configure($s);
|
||||
}
|
||||
|
||||
public static function table(Table $t): Table
|
||||
{
|
||||
return KeywordsTable::configure($t);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return ['index' => ListKeywords::route('/'), 'view' => ViewKeyword::route('/{record}')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Keywords\Pages;
|
||||
|
||||
use App\Filament\Resources\Keywords\KeywordResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListKeywords extends ListRecords
|
||||
{
|
||||
protected static string $resource = KeywordResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Keywords\Pages;
|
||||
|
||||
use App\Filament\Resources\Keywords\KeywordResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewKeyword extends ViewRecord
|
||||
{
|
||||
protected static string $resource = KeywordResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Keywords\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class KeywordInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextEntry::make('phrase')->label(__('admin.keyword')),
|
||||
TextEntry::make('project.name')->label(__('admin.project')),
|
||||
TextEntry::make('project.domain')->label(__('admin.domain')),
|
||||
TextEntry::make('project.user.email')->label(__('admin.customer')),
|
||||
TextEntry::make('search_engine')->label(__('admin.engine'))->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextEntry::make('country_code')->label(__('admin.country')),
|
||||
TextEntry::make('language_code')->label(__('admin.language')),
|
||||
TextEntry::make('device')->label(__('admin.device'))->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextEntry::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextEntry::make('ulid')->label('ULID'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Keywords\Tables;
|
||||
|
||||
use App\Enums\KeywordStatus;
|
||||
use App\Models\Keyword;
|
||||
use App\Support\KeywordTargetOptions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class KeywordsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table->modifyQueryUsing(fn ($query) => $query->with('project.user'))
|
||||
->columns([
|
||||
TextColumn::make('phrase')->label(__('admin.keyword'))->searchable()->sortable(),
|
||||
TextColumn::make('project.name')->label(__('admin.project'))->searchable(),
|
||||
TextColumn::make('project.domain')->label(__('admin.domain'))->searchable(),
|
||||
TextColumn::make('project.user.email')->label(__('admin.customer'))->searchable(),
|
||||
TextColumn::make('search_engine')->label(__('admin.engine'))->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('country_code')->label(__('admin.country')),
|
||||
TextColumn::make('language_code')->label(__('admin.language')),
|
||||
TextColumn::make('device')->label(__('admin.device'))->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
TextColumn::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')->options(['active' => __('states.active'), 'archived' => __('states.archived')]),
|
||||
SelectFilter::make('search_engine')->options(['google' => 'Google', 'bing' => 'Bing']),
|
||||
SelectFilter::make('country_code')->options(KeywordTargetOptions::countries()),
|
||||
SelectFilter::make('language_code')->options(KeywordTargetOptions::languages()),
|
||||
SelectFilter::make('device')->options(['desktop' => __('states.desktop'), 'mobile' => __('states.mobile')]),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
Action::make('archive')->label(__('admin.archive'))->requiresConfirmation()
|
||||
->visible(fn (Keyword $record) => $record->status === KeywordStatus::Active && auth()->user()->can('archive', $record))
|
||||
->action(fn (Keyword $record) => $record->archive()),
|
||||
Action::make('restore')->label(__('admin.restore'))
|
||||
->visible(fn (Keyword $record) => $record->status === KeywordStatus::Archived && auth()->user()->can('restore', $record))
|
||||
->action(fn (Keyword $record) => $record->restoreKeyword()),
|
||||
])->toolbarActions([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProjects extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewProject extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects;
|
||||
|
||||
use App\Filament\Resources\Projects\Pages\ListProjects;
|
||||
use App\Filament\Resources\Projects\Pages\ViewProject;
|
||||
use App\Filament\Resources\Projects\Schemas\ProjectInfolist;
|
||||
use App\Filament\Resources\Projects\Tables\ProjectsTable;
|
||||
use App\Models\Project;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Project::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('ui.projects');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('resources.project');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('ui.projects');
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return ProjectInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ProjectsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return ['index' => ListProjects::route('/'), 'view' => ViewProject::route('/{record}')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProjectInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextEntry::make('name')->label(__('admin.project')),
|
||||
TextEntry::make('domain')->label(__('admin.domain')),
|
||||
TextEntry::make('url')->label(__('admin.root_url'))->url(fn ($record) => $record->url)->openUrlInNewTab(),
|
||||
TextEntry::make('user.name')->label(__('admin.customer')),
|
||||
TextEntry::make('user.email')->label(__('admin.email')),
|
||||
TextEntry::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextEntry::make('ulid')->label(__('admin.project_id')),
|
||||
TextEntry::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i'),
|
||||
TextEntry::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Tables;
|
||||
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Models\Project;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('admin.project'))->searchable()->sortable(),
|
||||
TextColumn::make('domain')->label(__('admin.domain'))->searchable()->sortable(),
|
||||
TextColumn::make('user.name')->label(__('admin.customer'))->searchable()->sortable(),
|
||||
TextColumn::make('user.email')->label(__('admin.email'))->searchable(),
|
||||
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
TextColumn::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
])
|
||||
->filters([SelectFilter::make('status')->label(__('admin.status'))->options(['active' => __('states.active'), 'archived' => __('states.archived')])])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
Action::make('archive')->label(__('admin.archive'))->requiresConfirmation()
|
||||
->visible(fn (Project $record): bool => $record->status === ProjectStatus::Active && auth()->user()->can('archive', $record))
|
||||
->action(fn (Project $record) => $record->archive()),
|
||||
Action::make('restore')->label(__('admin.restore'))
|
||||
->visible(fn (Project $record): bool => $record->status === ProjectStatus::Archived && auth()->user()->can('restore', $record))
|
||||
->action(fn (Project $record) => $record->restoreProject()),
|
||||
])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\SerpChecks\Pages;
|
||||
|
||||
use App\Filament\Resources\SerpChecks\SerpCheckResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListSerpChecks extends ListRecords
|
||||
{
|
||||
protected static string $resource = SerpCheckResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\SerpChecks\Pages;
|
||||
|
||||
use App\Filament\Resources\SerpChecks\SerpCheckResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewSerpCheck extends ViewRecord
|
||||
{
|
||||
protected static string $resource = SerpCheckResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\SerpChecks\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class SerpCheckInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextEntry::make('ulid')->label('ULID'),
|
||||
TextEntry::make('phrase')->label(__('admin.keyword')),
|
||||
TextEntry::make('keyword.project.name')->label(__('admin.project')),
|
||||
TextEntry::make('keyword.project.domain')->label(__('admin.domain')),
|
||||
TextEntry::make('keyword.project.user.email')->label(__('admin.customer')),
|
||||
TextEntry::make('provider')->label(__('admin.provider'))->placeholder('—'),
|
||||
TextEntry::make('status')->label(__('admin.status'))->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextEntry::make('result_count')->label(__('admin.result_count')),
|
||||
TextEntry::make('error_code')->label(__('admin.error_code'))->placeholder('—'),
|
||||
TextEntry::make('error_message')->label(__('admin.error_message'))->placeholder('—'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\SerpChecks;
|
||||
|
||||
use App\Filament\Resources\SerpChecks\Pages\ListSerpChecks;
|
||||
use App\Filament\Resources\SerpChecks\Pages\ViewSerpCheck;
|
||||
use App\Filament\Resources\SerpChecks\Schemas\SerpCheckInfolist;
|
||||
use App\Filament\Resources\SerpChecks\Tables\SerpChecksTable;
|
||||
use App\Models\SerpCheck;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SerpCheckResource extends Resource
|
||||
{
|
||||
protected static ?string $model = SerpCheck::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('ui.serp_checks');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('resources.serp_check');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('ui.serp_checks');
|
||||
}
|
||||
|
||||
public static function infolist(Schema $s): Schema
|
||||
{
|
||||
return SerpCheckInfolist::configure($s);
|
||||
}
|
||||
|
||||
public static function table(Table $t): Table
|
||||
{
|
||||
return SerpChecksTable::configure($t);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return ['index' => ListSerpChecks::route('/'), 'view' => ViewSerpCheck::route('/{record}')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\SerpChecks\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SerpChecksTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table->modifyQueryUsing(fn ($query) => $query->with('keyword.project.user'))
|
||||
->columns([
|
||||
TextColumn::make('ulid')->label('ULID')->searchable(),
|
||||
TextColumn::make('phrase')->label(__('admin.keyword'))->searchable(),
|
||||
TextColumn::make('keyword.project.name')->label(__('admin.project'))->searchable(),
|
||||
TextColumn::make('keyword.project.domain')->label(__('admin.domain'))->searchable(),
|
||||
TextColumn::make('keyword.project.user.email')->label(__('admin.customer'))->searchable(),
|
||||
TextColumn::make('provider')->label(__('admin.provider'))->placeholder('—'),
|
||||
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('result_count')->label(__('admin.result_count')),
|
||||
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')->label(__('admin.status'))->options([
|
||||
'pending' => __('states.pending'), 'running' => __('states.running'),
|
||||
'completed' => __('states.completed'), 'failed' => __('states.failed'),
|
||||
]),
|
||||
SelectFilter::make('provider')->label(__('admin.provider')),
|
||||
SelectFilter::make('search_engine')->label(__('admin.search_engine'))->options(['google' => 'Google', 'bing' => 'Bing']),
|
||||
])->recordActions([ViewAction::make()])->toolbarActions([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\RoleAssignmentService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$roles = $data['roles'] ?? []; unset($data['roles']);
|
||||
$user = User::create($data);
|
||||
return app(RoleAssignmentService::class)->update(auth()->user(), $user, [], $roles);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use App\Services\RoleAssignmentService;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('roles.edit_user');
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$data['roles'] = $this->record->roles()->pluck('roles.id')->all();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
$roles = $data['roles'] ?? [];
|
||||
unset($data['roles']);
|
||||
|
||||
return app(RoleAssignmentService::class)->update(auth()->user(), $record, $data, $roles);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users\Schemas;
|
||||
|
||||
use App\Enums\RoleName;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\Role;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class UserForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextInput::make('name')->label(__('roles.fields.name'))->required()->maxLength(255),
|
||||
TextInput::make('email')->label(__('roles.fields.email'))->email()->required()->unique(ignoreRecord: true),
|
||||
TextInput::make('password')->label(__('roles.fields.password'))->password()->revealable()->required(fn (string $operation): bool => $operation === 'create')->minLength(8)->dehydrated(fn (?string $state): bool => filled($state)),
|
||||
Select::make('status')->label(__('roles.fields.status'))->required()->options(collect(UserStatus::cases())->mapWithKeys(fn ($s) => [$s->value => $s->label()])),
|
||||
Select::make('roles')->label(__('roles.fields.roles'))->multiple()->searchable()->preload()->required()
|
||||
->options(function (): array {
|
||||
$q = Role::query();
|
||||
if (! auth()->user()?->hasRole(RoleName::SuperAdmin)) {
|
||||
$q->where('name', '!=', RoleName::SuperAdmin->value);
|
||||
}
|
||||
|
||||
return $q->get()->sortBy(fn ($r) => array_search($r->name, ['super_admin', 'admin', 'support', 'customer'], true))->mapWithKeys(fn ($r) => [$r->id => __('roles.names.'.$r->name)])->all();
|
||||
})
|
||||
->helperText(__('roles.descriptions.help'))->visible(fn () => auth()->user()?->hasPermission('roles.assign') ?? false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users\Tables;
|
||||
|
||||
use App\Services\RoleAssignmentService;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UsersTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('roles.fields.name'))->searchable()->sortable(),
|
||||
TextColumn::make('email')->label(__('roles.fields.email'))->searchable()->sortable(),
|
||||
TextColumn::make('roles.name')->label(__('roles.fields.roles'))->badge()->formatStateUsing(fn ($state) => __('roles.names.'.$state))->wrap(),
|
||||
TextColumn::make('status')->label(__('roles.fields.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
|
||||
TextColumn::make('email_verified_at')->label(__('roles.fields.verified'))->dateTime('d.m.Y H:i')->placeholder(__('roles.not_verified'))->sortable(),
|
||||
TextColumn::make('last_login_at')->label(__('roles.fields.last_login'))->dateTime('d.m.Y H:i')->placeholder(__('roles.never'))->sortable(),
|
||||
TextColumn::make('created_at')->label(__('roles.fields.created_at'))->dateTime('d.m.Y H:i')->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('role')->label(__('roles.fields.roles'))->relationship('roles', 'name')->multiple()
|
||||
->getOptionLabelFromRecordUsing(fn ($record) => __('roles.names.'.$record->name)),
|
||||
SelectFilter::make('status')->label(__('roles.fields.status'))->options(['active' => __('states.active'), 'suspended' => __('states.suspended')]),
|
||||
TernaryFilter::make('verified')->label(__('roles.fields.verified'))
|
||||
->queries(true: fn ($query) => $query->whereNotNull('email_verified_at'), false: fn ($query) => $query->whereNull('email_verified_at')),
|
||||
])
|
||||
->recordActions(ActionGroup::make([
|
||||
EditAction::make()->label(__('roles.edit_user')),
|
||||
DeleteAction::make()
|
||||
->label(__('roles.delete'))
|
||||
->authorize('delete')
|
||||
->modalHeading(__('roles.delete_confirmation'))
|
||||
->modalDescription(fn ($record) => $record->name.' - '.$record->email)
|
||||
->modalSubmitActionLabel(__('roles.delete_user'))
|
||||
->modalCancelActionLabel(__('roles.cancel'))
|
||||
->successNotificationTitle(__('roles.deleted'))
|
||||
->action(function ($record): void {
|
||||
try {
|
||||
app(RoleAssignmentService::class)->delete(auth()->user(), $record);
|
||||
Notification::make()->success()->title(__('roles.deleted'))->send();
|
||||
} catch (ValidationException $exception) {
|
||||
Notification::make()->danger()->title(collect($exception->errors())->flatten()->first() ?? __('roles.delete_failed'))->send();
|
||||
}
|
||||
}),
|
||||
]))
|
||||
->toolbarActions([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Users;
|
||||
|
||||
use App\Filament\Resources\Users\Pages\CreateUser;
|
||||
use App\Filament\Resources\Users\Pages\EditUser;
|
||||
use App\Filament\Resources\Users\Pages\ListUsers;
|
||||
use App\Filament\Resources\Users\Schemas\UserForm;
|
||||
use App\Filament\Resources\Users\Tables\UsersTable;
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
public static function getNavigationLabel(): string { return __('ui.users'); }
|
||||
public static function getModelLabel(): string { return __('resources.user'); }
|
||||
public static function getPluralModelLabel(): string { return __('ui.users'); }
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return UserForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return UsersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListUsers::route('/'),
|
||||
'create' => CreateUser::route('/create'),
|
||||
'edit' => EditUser::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\RoleAssignmentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class UserDeletionController extends Controller
|
||||
{
|
||||
public function __invoke(User $user, RoleAssignmentService $service): RedirectResponse
|
||||
{
|
||||
$service->delete(request()->user(), $user);
|
||||
|
||||
return redirect()->route('filament.admin.resources.users.index')
|
||||
->with('status', __('roles.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Support\AuthenticatedRedirect;
|
||||
use App\Support\PortalSession;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimlik bilgilerini ve customer rolünü doğrulayıp müşteri oturumunu güvenle başlatır.
|
||||
*
|
||||
* @param Request $request Müşteri giriş formu ve oturum bilgileri.
|
||||
* @param AuthenticatedRedirect $redirect Güvenli müşteri yönlendirmesini belirleyen yardımcı.
|
||||
* @return RedirectResponse Güvenli intended adresine veya müşteri paneline yönlendirme.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function store(Request $request, AuthenticatedRedirect $redirect): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
|
||||
$key = Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages(['email' => __('ui.errors.throttle')]);
|
||||
}
|
||||
if (! Auth::attempt([...$credentials, 'status' => 'active'], $request->boolean('remember'))) {
|
||||
RateLimiter::hit($key, 60);
|
||||
throw ValidationException::withMessages(['email' => __('ui.errors.credentials')]);
|
||||
}
|
||||
if (! Auth::user()->canAccessCustomerPortal()) {
|
||||
RateLimiter::hit($key, 60);
|
||||
app(PortalSession::class)->terminate($request);
|
||||
throw ValidationException::withMessages(['email' => __('auth.portal_denied')]);
|
||||
}
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->regenerate();
|
||||
if (! in_array(Auth::user()->locale, SetLocale::SUPPORTED, true)) {
|
||||
Auth::user()->forceFill(['locale' => $request->session()->get('locale', 'tr')])->save();
|
||||
}
|
||||
|
||||
return redirect()->to($redirect->intended($request) ?? $redirect->landing());
|
||||
}
|
||||
|
||||
public function destroy(Request $request, PortalSession $sessions): RedirectResponse
|
||||
{
|
||||
$sessions->terminate($request);
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationController extends Controller
|
||||
{
|
||||
public function notice(Request $request): View|RedirectResponse
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail() ? redirect()->route('dashboard') : view('auth.verify-email');
|
||||
}
|
||||
|
||||
public function verify(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
$request->fulfill();
|
||||
return redirect()->route('dashboard')->with('status', __('ui.status.verified'));
|
||||
}
|
||||
|
||||
public function send(Request $request): RedirectResponse
|
||||
{
|
||||
if (! $request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification();
|
||||
return back()->with('status', __('ui.status.verification_sent'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
public function create(Request $request): View { return view('auth.reset-password', ['request' => $request]); }
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::defaults()]]);
|
||||
$status = Password::reset($request->only('email', 'password', 'password_confirmation', 'token'), function (User $user, string $password) {
|
||||
$user->forceFill(['password' => Hash::make($password), 'remember_token' => Str::random(60)])->save();
|
||||
event(new PasswordReset($user));
|
||||
});
|
||||
return $status === Password::PasswordReset
|
||||
? redirect()->route('login')->with('status', __('ui.status.password_reset'))
|
||||
: back()->withInput($request->only('email'))->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
public function create(): View { return view('auth.forgot-password'); }
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['email' => ['required', 'email']]);
|
||||
Password::sendResetLink($request->only('email'));
|
||||
return back()->with('status', __('ui.status.reset_link_sent'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function create(): View { return view('auth.register'); }
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
$user = User::create($data);
|
||||
$user->roles()->attach(Role::where('name', 'customer')->sole());
|
||||
event(new Registered($user));
|
||||
Auth::login($user);
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;use App\Http\Requests\{StoreCompetitorRequest,VisibilityReportRequest};use App\Models\{Project,ProjectCompetitor};use App\Services\{CompetitorAuditLogger,CompetitorReportService};use Illuminate\Database\QueryException;use Illuminate\Http\RedirectResponse;use Illuminate\Validation\ValidationException;use Illuminate\View\View;
|
||||
class CompetitorController extends Controller{public function index(VisibilityReportRequest$r,CompetitorReportService$s):View{$projects=$r->user()->projects()->active()->with(['competitors'=>fn($q)=>$q->orderBy('name')])->orderBy('name')->get();$project=$r->filled('project')?$projects->firstWhere('ulid',$r->input('project')):$projects->first();if($r->filled('project'))abort_unless($project,404);$competitors=$project?->competitors?->where('status','active')->values()??collect();$data=$project?$s->compare($project,$competitors,now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay()):null;return view('reports.competitors',compact('projects','project','competitors','data'));}public function store(StoreCompetitorRequest$r,Project$p,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p);if($p->competitors()->active()->count()>=(int)config('serp.competitor_limit',10))throw ValidationException::withMessages(['domain'=>__('competitors.limit')]);if($r->input('normalized_domain')===$p->domain)throw ValidationException::withMessages(['domain'=>__('competitors.self_domain')]);try{$c=$p->competitors()->create($r->safe()->only(['name','domain','normalized_domain'])+['created_by'=>$r->user()->id]);}catch(QueryException){throw ValidationException::withMessages(['domain'=>__('competitors.duplicate')]);}$a->record('created',$c,$r->user());return back()->with('status',__('competitors.created'));}public function update(StoreCompetitorRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);if($r->input('normalized_domain')===$p->domain)throw ValidationException::withMessages(['domain'=>__('competitors.self_domain')]);$before=$c->attributesToArray();try{$c->update($r->safe()->only(['name','domain','normalized_domain']));}catch(QueryException){throw ValidationException::withMessages(['domain'=>__('competitors.duplicate')]);}$a->record('updated',$c,$r->user(),$before);return back()->with('status',__('competitors.updated'));}public function archive(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);$before=$c->attributesToArray();$c->forceFill(['status'=>'archived','archived_at'=>now()])->save();$a->record('archived',$c,$r->user(),$before);return back();}public function restore(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);$before=$c->attributesToArray();$c->forceFill(['status'=>'active','archived_at'=>null])->save();$a->record('restored',$c,$r->user(),$before);return back();}public function show(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorReportService$s):View{$this->guard($r,$p,$c);$data=$s->compare($p,collect([$c]),now()->subDays(29),now());return view('reports.competitor-show',compact('p','c','data'));}private function guard($r,Project$p,?ProjectCompetitor$c=null):void{abort_unless($p->user_id===$r->user()->id,404);if($c)abort_unless($c->project_id===$p->id,404);}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Enums\{KeywordStatus,ProjectStatus,SerpCheckStatus}; use App\Models\Project; use Illuminate\Http\Request; use Illuminate\View\View;
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request):View
|
||||
{
|
||||
$days=in_array((int)$request->input('days'),[7,30,90],true)?(int)$request->input('days'):30;
|
||||
$projects=$request->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);
|
||||
$selected=null;if($request->filled('project')){$selected=$projects->firstWhere('ulid',$request->string('project')->toString());abort_unless($selected,404);}
|
||||
$projectIds=$selected?collect([$selected->id]):$projects->pluck('id');
|
||||
$keywords=$request->user()->projects()->whereIn('projects.id',$projectIds)->with(['keywords'=>fn($q)=>$q->where('status',KeywordStatus::Active)->with(['serpChecks'=>fn($q)=>$q->where('status',SerpCheckStatus::Completed)->where('checked_at','>=',now()->subDays($days))->latest('checked_at')->with(['results'=>fn($r)=>$r->where('is_project_domain',true)->orderBy('position')])])])->get()->flatMap(fn($p)=>$p->keywords->each->setRelation('project',$p));
|
||||
$rows=$keywords->map(function($keyword){$checks=$keyword->serpChecks->filter(fn($c)=>$c->results->isNotEmpty())->values();$current=$checks->get(0);$previous=$checks->get(1);$rank=$current?->results->min('position');$prior=$previous?->results->min('position');return ['keyword'=>$keyword,'project'=>$keyword->project,'current'=>$rank,'previous'=>$prior,'change'=>($rank&&$prior)?$prior-$rank:null,'checked_at'=>$current?->checked_at];})->sortByDesc('checked_at')->values();
|
||||
$measured=$rows->whereNotNull('current');
|
||||
return view('dashboard',['projects'=>$projects,'selectedProject'=>$selected,'days'=>$days,'rows'=>$rows->take(12),'winners'=>$rows->where('change','>',0)->sortByDesc('change')->take(5),'losers'=>$rows->where('change','<',0)->sortBy('change')->take(5),'metrics'=>['projects'=>$projects->count(),'keywords'=>$keywords->count(),'top10'=>$measured->where('current','<=',10)->count(),'average'=>$measured->isNotEmpty()?round($measured->avg('current'),1):null,'winners'=>$rows->where('change','>',0)->count(),'losers'=>$rows->where('change','<',0)->count()]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Earn;
|
||||
|
||||
use App\Enums\EarnApiTokenStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EarnApiToken;
|
||||
use App\Models\EarnMember;
|
||||
use App\Services\EarnAuditLogger;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EarnApiKeyController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$member = $this->member($request);
|
||||
$tokens = $member->tokens()->latest()->get();
|
||||
|
||||
return response()->view('earn-panel.api-keys', [
|
||||
'tokens' => $tokens,
|
||||
'maxTotal' => (int) config('earn.api_keys.max_total'),
|
||||
'durations' => config('earn.api_keys.allowed_durations'),
|
||||
'defaultDuration' => config('earn.api_keys.default_duration'),
|
||||
'tasksUrl' => route('test-tasks.index'),
|
||||
])->withHeaders(['Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache']);
|
||||
}
|
||||
|
||||
public function store(Request $request, EarnAuditLogger $audit): RedirectResponse
|
||||
{
|
||||
$request->merge(['name' => trim(strip_tags((string) $request->input('name')))]);
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'min:3', 'max:80'],
|
||||
'duration' => ['required', 'string', Rule::in(config('earn.api_keys.allowed_durations'))],
|
||||
]);
|
||||
$member = $this->member($request);
|
||||
$result = DB::transaction(function () use ($member, $data, $request, $audit): array {
|
||||
EarnMember::query()->whereKey($member->id)->lockForUpdate()->firstOrFail();
|
||||
if ($member->tokens()->count() >= (int) config('earn.api_keys.max_total')) {
|
||||
return ['error' => 'total'];
|
||||
}
|
||||
|
||||
$plainText = 'tpe_'.rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '=');
|
||||
$expiresAt = $this->expiresAt($data['duration']);
|
||||
$token = $member->tokens()->create([
|
||||
'name' => $data['name'], 'token' => hash('sha256', $plainText), 'token_hint' => 'tpe_'.substr($plainText, -4),
|
||||
'abilities' => ['tasks:read'], 'is_active' => true, 'expires_at' => $expiresAt,
|
||||
]);
|
||||
$audit->record('api_key_created', $member, $request, $this->metadata($token));
|
||||
|
||||
return ['token' => $plainText];
|
||||
}, 3);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$audit->record('api_key_limit_rejected', $member, $request, ['device_name' => $data['name'], 'abilities' => ['tasks:read'], 'limit' => $result['error']]);
|
||||
throw ValidationException::withMessages(['api_key' => __('earn_portal.total_limit_reached')]);
|
||||
}
|
||||
|
||||
return redirect()->route('earn.api-keys.index')->with('new_api_key', $result['token']);
|
||||
}
|
||||
|
||||
public function status(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$member = $this->member($request);
|
||||
DB::transaction(function () use ($member, $apiKey, $data, $request, $audit): void {
|
||||
EarnMember::query()->whereKey($member->id)->lockForUpdate()->firstOrFail();
|
||||
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
|
||||
abort_if(in_array($token->status(), [EarnApiTokenStatus::Expired, EarnApiTokenStatus::Revoked], true), 422);
|
||||
$activate = (bool) $data['is_active'];
|
||||
if ($token->is_active === $activate) {
|
||||
return;
|
||||
}
|
||||
$token->forceFill(['is_active' => $activate])->save();
|
||||
$audit->record($activate ? 'api_key_activated' : 'api_key_deactivated', $member, $request, $this->metadata($token));
|
||||
}, 3);
|
||||
|
||||
return back()->with('status', __('earn_portal.status_updated'));
|
||||
}
|
||||
|
||||
public function revoke(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
|
||||
{
|
||||
$member = $this->member($request);
|
||||
DB::transaction(function () use ($member, $apiKey, $request, $audit): void {
|
||||
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
|
||||
abort_if($token->revoked_at !== null, 404);
|
||||
$token->forceFill(['revoked_at' => now(), 'is_active' => false])->save();
|
||||
$audit->record('api_key_revoked', $member, $request, $this->metadata($token));
|
||||
}, 3);
|
||||
|
||||
return back()->with('status', __('earn_portal.key_revoked'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
|
||||
{
|
||||
$member = $this->member($request);
|
||||
DB::transaction(function () use ($member, $apiKey, $request, $audit): void {
|
||||
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
|
||||
$token->forceFill(['revoked_at' => $token->revoked_at ?? now(), 'is_active' => false])->save();
|
||||
$metadata = $this->metadata($token) + ['deleted_at' => now()->toIso8601String(), 'actor_earn_member_id' => $member->id];
|
||||
$audit->record('api_key_deleted', $member, $request, $metadata);
|
||||
$token->delete();
|
||||
}, 3);
|
||||
|
||||
return back()->with('status', __('earn_portal.key_deleted'));
|
||||
}
|
||||
|
||||
private function expiresAt(string $duration): ?Carbon
|
||||
{
|
||||
return match ($duration) {
|
||||
'1_day' => now()->addDay(), '7_days' => now()->addDays(7), '30_days' => now()->addDays(30),
|
||||
'90_days' => now()->addDays(90), '180_days' => now()->addDays(180), '1_year' => now()->addYear(),
|
||||
'unlimited' => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function metadata(EarnApiToken $token): array
|
||||
{
|
||||
return ['token_id' => $token->id, 'device_name' => $token->name, 'abilities' => $token->abilities,
|
||||
'created_at' => $token->created_at?->toIso8601String(), 'expires_at' => $token->expires_at?->toIso8601String(),
|
||||
'revoked_at' => $token->revoked_at?->toIso8601String()];
|
||||
}
|
||||
|
||||
private function member(Request $request): EarnMember
|
||||
{
|
||||
$member = $request->user('earn');
|
||||
abort_unless($member instanceof EarnMember, 403);
|
||||
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Earn;
|
||||
|
||||
use App\Enums\EarnLegalDocumentType;
|
||||
use App\Enums\EarnMemberStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EarnMember;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EarnAuthController extends Controller
|
||||
{
|
||||
public function loginForm(): View
|
||||
{
|
||||
return view('earn-auth.login');
|
||||
}
|
||||
|
||||
public function registerForm(): View
|
||||
{
|
||||
return view('earn-auth.register');
|
||||
}
|
||||
|
||||
public function register(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:earn_members,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'terms_accepted' => ['accepted'],
|
||||
'privacy_notice_acknowledged' => ['accepted'],
|
||||
], [
|
||||
'terms_accepted.accepted' => __('earn_legal.validation.terms_accepted'),
|
||||
'privacy_notice_acknowledged.accepted' => __('earn_legal.validation.privacy_notice_acknowledged'),
|
||||
]);
|
||||
|
||||
$member = DB::transaction(function () use ($data, $request): EarnMember {
|
||||
$member = EarnMember::query()->create([
|
||||
'name' => trim($data['name']),
|
||||
'email' => Str::lower($data['email']),
|
||||
'password' => $data['password'],
|
||||
'status' => EarnMemberStatus::Pending,
|
||||
]);
|
||||
|
||||
$context = ['accepted_at' => now(), 'ip_address' => $request->ip(), 'user_agent' => Str::limit((string) $request->userAgent(), 1000, '')];
|
||||
$member->legalAcceptances()->create($context + ['document_type' => EarnLegalDocumentType::Terms, 'document_version' => config('earn.legal.terms.version')]);
|
||||
$member->legalAcceptances()->create($context + ['document_type' => EarnLegalDocumentType::PrivacyNotice, 'document_version' => config('earn.legal.privacy.version')]);
|
||||
|
||||
return $member;
|
||||
});
|
||||
|
||||
event(new Registered($member));
|
||||
Auth::guard('earn')->login($member);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('earn.verification.notice');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
$key = 'earn-login:'.Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages(['email' => __('ui.errors.throttle')]);
|
||||
}
|
||||
|
||||
if (! Auth::guard('earn')->attempt($credentials, $request->boolean('remember'))) {
|
||||
RateLimiter::hit($key, 60);
|
||||
throw ValidationException::withMessages(['email' => __('ui.errors.credentials')]);
|
||||
}
|
||||
|
||||
$member = Auth::guard('earn')->user();
|
||||
if (! $member->canUseApi()) {
|
||||
Auth::guard('earn')->logout();
|
||||
RateLimiter::hit($key, 60);
|
||||
throw ValidationException::withMessages(['email' => __('earn_portal.account_unavailable')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->regenerate();
|
||||
$member->forceFill(['last_login_at' => now(), 'last_login_ip' => $request->ip()])->saveQuietly();
|
||||
|
||||
return redirect()->intended(route('earn.dashboard'));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('earn')->logout();
|
||||
$request->session()->regenerate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('earn.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Earn;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EarnDashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$member = auth('earn')->user();
|
||||
$activeTokenCount = $member->tokens()
|
||||
->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->count();
|
||||
|
||||
return view('earn-panel.dashboard', compact('member', 'activeTokenCount'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Earn;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EarnLegalPageController extends Controller
|
||||
{
|
||||
public function terms(): View
|
||||
{
|
||||
return $this->page('terms');
|
||||
}
|
||||
|
||||
public function privacy(): View
|
||||
{
|
||||
return $this->page('privacy');
|
||||
}
|
||||
|
||||
private function page(string $document): View
|
||||
{
|
||||
return view('earn-legal.document', [
|
||||
'document' => $document,
|
||||
'content' => __('earn_legal.'.$document),
|
||||
'version' => config('earn.legal.'.$document.'.version'),
|
||||
'effectiveDate' => config('earn.legal.'.$document.'.effective_date'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Earn;
|
||||
|
||||
use App\Enums\EarnMemberStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EarnVerificationController extends Controller
|
||||
{
|
||||
public function notice(): View|RedirectResponse
|
||||
{
|
||||
if (auth('earn')->user()->hasVerifiedEmail()) {
|
||||
return redirect()->route('earn.dashboard');
|
||||
}
|
||||
|
||||
return view('earn-auth.verify');
|
||||
}
|
||||
|
||||
public function send(): RedirectResponse
|
||||
{
|
||||
if (! auth('earn')->user()->hasVerifiedEmail()) {
|
||||
auth('earn')->user()->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return back()->with('status', __('earn_portal.verification_sent'));
|
||||
}
|
||||
|
||||
public function verify(Request $request): RedirectResponse
|
||||
{
|
||||
$member = auth('earn')->user();
|
||||
abort_unless((string) $member->getKey() === (string) $request->route('id'), 403);
|
||||
abort_unless(hash_equals((string) $request->route('hash'), sha1($member->getEmailForVerification())), 403);
|
||||
|
||||
if (! $member->hasVerifiedEmail()) {
|
||||
$member->forceFill(['email_verified_at' => now(), 'status' => EarnMemberStatus::Active])->save();
|
||||
}
|
||||
|
||||
return redirect()->route('earn.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EarnApiToken;
|
||||
use App\Services\EarnApiAccessLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class EarnDeviceController extends Controller
|
||||
{
|
||||
public function bind(Request $request, EarnApiAccessLogger $logs): JsonResponse
|
||||
{
|
||||
$token = $request->attributes->get('earn_api_token');
|
||||
abort_unless($token instanceof EarnApiToken, 401);
|
||||
$hash = $request->attributes->get('earn_device_hash');
|
||||
$preview = $request->attributes->get('earn_device_preview');
|
||||
$result = DB::transaction(function () use ($token, $hash, $preview, $request): array {
|
||||
$locked = EarnApiToken::query()->whereKey($token->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->device_id_hash !== null) {
|
||||
$match = hash_equals($locked->device_id_hash, $hash);
|
||||
if ($match) {
|
||||
$locked->forceFill(['last_ip' => $request->ip(), 'last_seen_at' => now()])->save();
|
||||
}
|
||||
|
||||
return ['token' => $locked, 'match' => $match];
|
||||
}
|
||||
$locked->forceFill(['device_id_hash' => $hash, 'device_id_preview' => $preview, 'device_bound_at' => now(),
|
||||
'first_ip' => $request->ip(), 'last_ip' => $request->ip(), 'last_seen_at' => now()])->save();
|
||||
|
||||
return ['token' => $locked, 'match' => true, 'created' => true];
|
||||
}, 3);
|
||||
if (! $result['match']) {
|
||||
$failedKey = 'earn-api:failed-device:'.hash('sha256', $result['token']->id.'|'.$request->ip());
|
||||
$failedLimit = (int) config('earn.api.failed_device_per_minute');
|
||||
if (RateLimiter::tooManyAttempts($failedKey, $failedLimit)) {
|
||||
$logs->record($request, 'rate_limited', 429, 'RATE_LIMITED', $result['token'], $hash, $preview);
|
||||
|
||||
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($failedKey));
|
||||
}
|
||||
RateLimiter::hit($failedKey, 60);
|
||||
$result['token']->forceFill(['device_mismatch_seen' => true])->saveQuietly();
|
||||
$logs->record($request, 'device_mismatch', 409, 'DEVICE_MISMATCH', $result['token'], $hash, $preview);
|
||||
|
||||
return $this->error('DEVICE_MISMATCH', 409);
|
||||
}
|
||||
$already = ! ($result['created'] ?? false);
|
||||
$logs->record($request, $already ? 'device_bind_repeated' : 'device_bound', 200, null, $result['token'], $hash, $preview);
|
||||
|
||||
return response()->json(['success' => true, 'data' => ['device_bound' => true, 'already_bound' => $already,
|
||||
'device_id_preview' => $result['token']->device_id_preview, 'bound_at' => $result['token']->device_bound_at?->toISOString()]])
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
private function error(string $code, int $status): JsonResponse
|
||||
{
|
||||
return response()->json(['success' => false, 'error' => ['code' => $code, 'message' => __('earn_api.'.$code)]], $status)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreEarnTaskResultsRequest;
|
||||
use App\Services\EarnApiAccessLogger;
|
||||
use App\Services\EarnTaskResultService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class EarnTaskResultController extends Controller
|
||||
{
|
||||
/**
|
||||
* Doğrulanmış istemciden gelen görev sonuçlarını kısmi kabul özetiyle kaydeder.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function store(StoreEarnTaskResultsRequest $request, EarnTaskResultService $service, EarnApiAccessLogger $logs): JsonResponse
|
||||
{
|
||||
$items = $request->validated('tasks');
|
||||
$logs->record($request, 'task_results_received', 200);
|
||||
$data = $service->store($request, $items);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Enums\{KeywordStatus,ProjectStatus,SerpCheckStatus};use App\Http\Requests\{StoreKeywordRequest,UpdateKeywordRequest};use App\Models\{Keyword,Project};use App\Services\{KeywordAuditLogger,KeywordPhraseNormalizer,NextCheckAtCalculator};use App\Support\KeywordTargetOptions;use Illuminate\Database\QueryException;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Support\Facades\DB;use Illuminate\Validation\{Rule,ValidationException};use Illuminate\View\View;
|
||||
class KeywordController extends Controller{
|
||||
public function __construct(private readonly KeywordAuditLogger $keywordAuditLogger,private readonly NextCheckAtCalculator $nextCheckAtCalculator){}
|
||||
public function legacyShow(Project $customerProject,Keyword $customerKeyword):View{return $this->show(request(),$customerKeyword);}
|
||||
public function legacyEdit(Project $customerProject,Keyword $customerKeyword):View{return $this->edit(request(),$customerKeyword);}
|
||||
public function legacyUpdate(UpdateKeywordRequest $r,Project $customerProject,Keyword $customerKeyword,KeywordPhraseNormalizer $n):RedirectResponse{return $this->update($r,$n);}
|
||||
public function legacyArchive(Request $r,Project $customerProject,Keyword $customerKeyword):RedirectResponse{return $this->archive($r,$customerKeyword);}
|
||||
public function legacyRestore(Request $r,Project $customerProject,Keyword $customerKeyword):RedirectResponse{return $this->restore($r,$customerKeyword);}
|
||||
public function index(Request $r):View{
|
||||
$this->authorize('viewAny',Keyword::class);$projects=$r->user()->projects()->orderBy('name')->get();$nested=$r->route('customerProject');$projectUlid=$nested?->ulid?:$r->string('project')->toString();
|
||||
$q=Keyword::query()->whereHas('project',fn($x)=>$x->where('user_id',$r->user()->id))->with(['project','serpChecks'=>fn($x)=>$x->where('status',SerpCheckStatus::Completed)->latest('checked_at')->with(['results'=>fn($y)=>$y->where('is_project_domain',true)->orderBy('position')])]);
|
||||
if($projectUlid)$q->whereHas('project',fn($x)=>$x->where('ulid',$projectUlid));if($s=trim($r->string('q')->toString()))$q->where('phrase','like','%'.addcslashes($s,'%_').'%');
|
||||
foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine'] as $column=>$key)if($v=$r->string($key)->toString())$q->where($column,$v);
|
||||
$requestedStatus=$r->input('status',$r->input('filter'));$status=in_array($requestedStatus,['active','inactive','archived'],true)?$requestedStatus:null;if($status==='archived')$q->where('status',KeywordStatus::Archived)->whereNotNull('archived_at');elseif($status)$q->where('status',$status)->whereNull('archived_at');else$q->whereIn('status',[KeywordStatus::Active,KeywordStatus::Inactive])->whereNull('archived_at');$sort=in_array($r->input('sort'),['phrase','created_at','updated_at'],true)?$r->input('sort'):'created_at';$direction=$r->input('direction')==='asc'?'asc':'desc';$keywords=$q->orderBy($sort,$direction)->paginate(15)->withQueryString();
|
||||
$keywords->getCollection()->each(fn($k)=>$this->rankData($k));$all=(clone $q)->get();$all->each(fn($k)=>$this->rankData($k));$ranked=$all->whereNotNull('current_rank');$summary=['total'=>$all->count(),'active'=>$all->where('status',KeywordStatus::Active)->count(),'top10'=>$ranked->where('current_rank','<=',10)->count(),'average'=>$ranked->isEmpty()?null:round($ranked->avg('current_rank'),1),'winners'=>$all->where('movement','winner')->count(),'losers'=>$all->where('movement','loser')->count()];
|
||||
return view('keywords.index',compact('keywords','projects','summary','projectUlid')+$this->options());
|
||||
}
|
||||
public function create(Request $r):View{$project=$r->route('customerProject')?:$r->user()->projects()->active()->where('ulid',$r->input('project'))->first();$projects=$r->user()->projects()->active()->orderBy('name')->get();abort_if($projects->isEmpty(),404);$project??=$projects->first();$this->authorize('create',[Keyword::class,$project]);return view('keywords.create',compact('project','projects')+$this->options());}
|
||||
public function store(StoreKeywordRequest $r,KeywordPhraseNormalizer $normalizer):RedirectResponse{
|
||||
$project=$r->project();$seen=[];$targets=[];$duplicates=0;$rejected=0;foreach(preg_split('/\R/u',$r->string('phrases')->toString())?:[] as $line){if(trim($line)==='')continue;try{$data=$normalizer->normalize($line);}catch(\InvalidArgumentException){$rejected++;continue;}$key=$data['normalized_phrase'];if(isset($seen[$key])){$duplicates++;continue;}$seen[$key]=true;$target=$data+['search_engine'=>$r->input('search_engine'),'country_code'=>strtoupper($r->input('country_code')),'language_code'=>strtolower($r->input('language_code')),'device'=>$r->input('device')];if($project->keywords()->where($target)->exists()){$duplicates++;continue;}$targets[]=$target;}
|
||||
$errorKey=$r->has('phrase')?'phrase':'phrases';if(!$targets)throw ValidationException::withMessages([$errorKey=>__('keywords.validation.none_created')]);$created=DB::transaction(function()use($targets,$project,$r,$errorKey){$created=[];foreach($targets as$target){try{$created[]=$project->keywords()->create($target);}catch(QueryException){continue;}}if(!$created)throw ValidationException::withMessages([$errorKey=>__('keywords.validation.none_created')]);foreach($created as$k)$this->keywordAuditLogger->record(count($created)>1?'bulk_created':'created',$k,$r->user());return$created;});return redirect()->route('keywords.index',['project'=>$project->ulid])->with('status',__('keywords.created_result',['created'=>count($created),'duplicates'=>$duplicates,'rejected'=>$rejected]));
|
||||
}
|
||||
public function show(Request $r,?Keyword $keyword=null):View{$keyword=$this->keyword($r,$keyword);$this->authorize('view',$keyword);$keyword->load(['project','serpChecks'=>fn($q)=>$q->where('status',SerpCheckStatus::Completed)->latest('checked_at')->with(['results'=>fn($x)=>$x->orderBy('position')])]);$this->rankData($keyword);$history=$keyword->serpChecks->map(fn($c)=>['date'=>$c->checked_at?->toIso8601String(),'rank'=>$c->results->firstWhere('is_project_domain',true)?->position])->filter(fn($x)=>$x['rank']!==null)->reverse()->values();$best=$history->min('rank');return view('keywords.show',compact('keyword','history','best')+$this->options());}
|
||||
public function edit(Request $r,?Keyword $keyword=null):View{$keyword=$this->keyword($r,$keyword);$this->authorize('update',$keyword);abort_if($keyword->status===KeywordStatus::Archived||$keyword->archived_at!==null,422);return view('keywords.edit',['keyword'=>$keyword,'project'=>$keyword->project]+$this->options());}
|
||||
public function update(UpdateKeywordRequest $r,KeywordPhraseNormalizer $n):RedirectResponse{$k=$r->keyword();$before=$k->attributesToArray();$status=KeywordStatus::from($r->input('status'));$data=$n->normalize($r->string('phrase'))+['country_code'=>strtoupper($r->input('country_code')),'language_code'=>strtolower($r->input('language_code')),'device'=>$r->input('device')];$changed=collect($data)->contains(function($v,$key)use($k){$current=$k->{$key};return($current instanceof \BackedEnum?$current->value:(string)$current)!==$v;});try{DB::transaction(function()use($k,$data,$r,$before,$changed,$status){if($changed&&$k->serpChecks()->exists()){$k->archive();$this->keywordAuditLogger->record('archived_for_retarget',$k,$r->user());$new=$k->project->keywords()->create($data+['search_engine'=>$k->search_engine->value]);$new->forceFill(['status'=>$status,'tracking_enabled'=>$k->tracking_enabled,'check_frequency'=>$k->check_frequency,'next_check_at'=>$status===KeywordStatus::Active&&$k->tracking_enabled?$this->nextCheckAtCalculator->calculate($k->check_frequency):null])->save();$this->keywordAuditLogger->record('created_from_retarget',$new,$r->user());}else{$k->forceFill($data+['status'=>$status,'next_check_at'=>$status===KeywordStatus::Active&&$k->tracking_enabled?$this->nextCheckAtCalculator->calculate($k->check_frequency):null])->save();$this->keywordAuditLogger->record('updated',$k,$r->user(),$before);}});}catch(QueryException){throw ValidationException::withMessages(['phrase'=>__('keywords.validation.duplicate')]);}return redirect()->route('keywords.index',['project'=>$k->project->ulid])->with('status',__('keywords.updated'));}
|
||||
public function status(Request $r,Keyword $keyword):RedirectResponse{$this->authorize('update',$keyword);abort_if($keyword->status===KeywordStatus::Archived||$keyword->archived_at!==null,422);$data=$r->validate(['status'=>['required',Rule::in([KeywordStatus::Active->value,KeywordStatus::Inactive->value])]]);$status=KeywordStatus::from($data['status']);if($keyword->status===$status)return back()->with('status',__('keywords.updated'));DB::transaction(function()use($keyword,$status,$r){$before=$keyword->attributesToArray();$keyword->forceFill(['status'=>$status,'next_check_at'=>$status===KeywordStatus::Active&&$keyword->tracking_enabled?$this->nextCheckAtCalculator->calculate($keyword->check_frequency):null])->save();$this->keywordAuditLogger->record('status_changed',$keyword,$r->user(),$before);});return back()->with('status',__('keywords.updated'));}
|
||||
public function archive(Request $r,?Keyword $keyword=null):RedirectResponse{$k=$this->keyword($r,$keyword);$this->authorize('update',$k);if($k->status===KeywordStatus::Archived&&$k->archived_at!==null)return back()->with('status',__('keywords.archived'));DB::transaction(function()use($k,$r){$before=$k->attributesToArray();$k->archive();$this->keywordAuditLogger->record('archived',$k,$r->user(),$before);});return back()->with('status',__('keywords.archived'));}
|
||||
public function restore(Request $r,?Keyword $keyword=null):RedirectResponse{$k=$this->keyword($r,$keyword);$this->authorize('update',$k);if($k->status===KeywordStatus::Active&&$k->archived_at===null)return back()->with('status',__('keywords.restored'));abort_unless($k->project->status===ProjectStatus::Active&&$k->project->archived_at===null,422);$duplicate=$k->project->keywords()->active()->where('normalized_phrase',$k->normalized_phrase)->where('search_engine',$k->search_engine)->where('country_code',$k->country_code)->where('language_code',$k->language_code)->where('device',$k->device)->whereKeyNot($k->id)->exists();if($duplicate)throw ValidationException::withMessages(['keyword'=>__('keywords.validation.restore_duplicate')]);DB::transaction(function()use($k,$r){$before=$k->attributesToArray();$k->restoreKeyword();$this->keywordAuditLogger->record('restored',$k,$r->user(),$before);});return back()->with('status',__('keywords.restored'));}
|
||||
private function keyword(Request $r,?Keyword $keyword):Keyword{return $keyword?:($r->route('customerKeyword')?:$r->route('keyword'));}
|
||||
private function rankData(Keyword $k):void{$checks=$k->serpChecks->values();$current=$checks->get(0)?->results->firstWhere('is_project_domain',true)?->position;$previous=$checks->get(1)?->results->firstWhere('is_project_domain',true)?->position;$k->setAttribute('current_rank',$current);$k->setAttribute('previous_rank',$previous);$k->setAttribute('rank_change',$current&&$previous?$previous-$current:null);$k->setAttribute('movement',$current===null||$previous===null?'new':($current<$previous?'winner':($current>$previous?'loser':'unchanged')));$k->setAttribute('last_checked_at',$checks->get(0)?->checked_at);}
|
||||
private function options():array{return ['countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()];}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LocaleController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'locale' => ['required', 'in:'.implode(',', SetLocale::SUPPORTED)],
|
||||
'return_to' => ['nullable', 'string', 'max:2048'],
|
||||
]);
|
||||
$locale = $data['locale'];
|
||||
$request->session()->put('locale', $locale);
|
||||
Cookie::queue(cookie('locale', $locale, 60 * 24 * 365, '/', null, $request->isSecure(), true, false, 'lax'));
|
||||
|
||||
if ($request->user()) {
|
||||
$request->user()->forceFill(['locale' => $locale])->save();
|
||||
}
|
||||
|
||||
$target = $this->safeRelativeTarget($data['return_to'] ?? null)
|
||||
?? $this->safeRefererTarget($request)
|
||||
?? $this->fallbackTarget($request);
|
||||
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
private function safeRelativeTarget(?string $target): ?string
|
||||
{
|
||||
if (! is_string($target) || ! str_starts_with($target, '/') || str_starts_with($target, '//')) return null;
|
||||
$decoded = $target;
|
||||
for ($i = 0; $i < 3; $i++) $decoded = rawurldecode($decoded);
|
||||
if (! str_starts_with($decoded, '/') || str_starts_with($decoded, '//') || Str::contains($decoded, ["\r", "\n", '\\'])) return null;
|
||||
if (parse_url($decoded, PHP_URL_HOST) !== null || parse_url($decoded, PHP_URL_SCHEME) !== null) return null;
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function safeRefererTarget(Request $request): ?string
|
||||
{
|
||||
$referer = $request->headers->get('referer');
|
||||
if (! $referer) return null;
|
||||
$parts = parse_url($referer);
|
||||
$requestPort = $request->getPort();
|
||||
$refererPort = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
|
||||
if (($parts['scheme'] ?? null) !== $request->getScheme() || ($parts['host'] ?? null) !== $request->getHost() || $refererPort !== $requestPort) return null;
|
||||
return $this->safeRelativeTarget(($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''));
|
||||
}
|
||||
|
||||
private function fallbackTarget(Request $request): string
|
||||
{
|
||||
if ($request->user()?->canAccessPanel(filament()->getPanel('admin'))) return '/admin';
|
||||
if ($request->user()) return route('dashboard', absolute: false);
|
||||
return route('login', absolute: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;use App\Http\Requests\LosersReportRequest;use App\Models\Keyword;use App\Services\LosersReportService;use App\Support\KeywordTargetOptions;use Illuminate\Pagination\LengthAwarePaginator;use Illuminate\View\View;
|
||||
class LosersReportController extends Controller{public function __invoke(LosersReportRequest$r,LosersReportService$s):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$p=$projects->firstWhere('ulid',$r->input('project'));abort_unless($p,404);$keywords=$keywords->where('project_id',$p->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();if($q=mb_strtolower(trim($r->string('q')->toString())))$keywords=$keywords->filter(fn($k)=>str_contains(mb_strtolower($k->phrase),$q))->values();$all=$s->rows($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay());$rows=$s->losers($all);if($r->filled('min_lost'))$rows=$rows->filter(fn($x)=>($x['lost']??0)>=(int)$r->input('min_lost'))->values();if($r->filled('min_visibility_loss'))$rows=$rows->where('visibility_loss','>=',(float)$r->input('min_visibility_loss'))->values();if($type=$r->input('loss_type'))$rows=$rows->filter(fn($x)=>match($type){'top3'=>$x['category']==='dropped_top3','top10'=>in_array($x['category'],['dropped_top3','dropped_top10'],true),'top100'=>$x['dropped_out'],'dropped_out'=>$x['dropped_out']});$sort=in_array($r->input('sort'),['current','lost','visibility_loss','checked_at'],true)?$r->input('sort'):'lost';$rows=$rows->sortByDesc(fn($x)=>$x['dropped_out']?PHP_INT_MAX:($x[$sort]??-1))->values();$page=max(1,(int)$r->input('page',1));$pager=new LengthAwarePaginator($rows->forPage($page,25)->values(),$rows->count(),25,$page,['path'=>$r->url(),'query'=>$r->query()]);return view('reports.losers',['projects'=>$projects,'rows'=>$pager,'summary'=>$s->summary($rows),'distribution'=>$s->distribution($rows),'critical'=>$s->critical($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;use App\Services\ProfileAuditLogger;use Illuminate\Auth\Events\Registered;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Support\Facades\{Auth,DB,Hash};use Illuminate\Validation\Rules\Password;use Illuminate\Validation\ValidationException;use Illuminate\View\View;
|
||||
class ProfileController extends Controller{public function show(Request$r):View{$u=$r->user()->load('roles');$sessions=config('session.driver')==='database'?DB::table('sessions')->where('user_id',$u->id)->orderByDesc('last_activity')->get()->map(fn($s)=>['id'=>$s->id,'current'=>$s->id===$r->session()->getId(),'agent'=>mb_substr((string)$s->user_agent,0,100),'last_activity'=>date('d.m.Y H:i',$s->last_activity)]):collect();return view('profile.show',compact('u','sessions'));}public function update(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['name'=>['required','string','min:2','max:150','not_regex:/[\x00-\x1F\x7F]/']]);$before=$r->user()->name;$r->user()->update(['name'=>trim($data['name'])]);$a->record('profile_updated',$r->user(),['name'=>$before],['name'=>$r->user()->name]);return back()->with('status',__('profile.updated'));}public function email(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['email'=>['required','email:rfc','max:254','unique:users,email,'.$r->user()->id],'current_password'=>['required','current_password']]);$email=mb_strtolower(trim($data['email']));if($email===$r->user()->email)return back();$before=$r->user()->email;$r->user()->forceFill(['email'=>$email,'email_verified_at'=>null])->save();$r->user()->sendEmailVerificationNotification();$a->record('email_changed',$r->user(),['email'=>$a->mask($before)],['email'=>$a->mask($email)]);return back()->with('status',__('profile.email_updated'));}public function password(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['current_password'=>['required','current_password'],'password'=>['required','confirmed',Password::defaults()]]);if(Hash::check($data['password'],$r->user()->password))throw ValidationException::withMessages(['password'=>__('profile.same_password')]);$r->user()->forceFill(['password'=>Hash::make($data['password']),'remember_token'=>str()->random(60)])->save();$a->record('password_changed',$r->user());return back()->with('status',__('profile.password_updated'));}public function resend(Request$r,ProfileAuditLogger$a):RedirectResponse{if(!$r->user()->hasVerifiedEmail()){$r->user()->sendEmailVerificationNotification();$a->record('verification_resent',$r->user());}return back()->with('status',__('profile.verification_sent'));}public function logoutOthers(Request$r,ProfileAuditLogger$a):RedirectResponse{$r->validate(['current_password'=>['required','current_password']]);if(config('session.driver')==='database')DB::table('sessions')->where('user_id',$r->user()->id)->where('id','!=',$r->session()->getId())->delete();$a->record('other_sessions_logged_out',$r->user());return back()->with('status',__('profile.sessions_closed'));}}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectUrlNormalizer;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Project::class);
|
||||
$archived = $request->string('filter')->toString() === 'archived';
|
||||
$projects = $request->user()->projects()
|
||||
->where('status', $archived ? ProjectStatus::Archived : ProjectStatus::Active)
|
||||
->withCount(['keywords' => fn ($query) => $query->active()])
|
||||
->orderByDesc('id')->paginate(15)->withQueryString();
|
||||
return view('projects.index', compact('projects', 'archived'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', Project::class);
|
||||
return view('projects.create');
|
||||
}
|
||||
|
||||
public function store(StoreProjectRequest $request, ProjectUrlNormalizer $normalizer): RedirectResponse
|
||||
{
|
||||
$normalized = $normalizer->normalize($request->string('website_url')->toString());
|
||||
try {
|
||||
$project = $request->user()->projects()->create([
|
||||
'name' => $request->string('name')->trim()->toString(),
|
||||
'domain' => $normalized->domain,
|
||||
'url' => $normalized->url,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
$this->throwDuplicateValidation($exception);
|
||||
}
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Proje oluşturuldu.');
|
||||
}
|
||||
|
||||
public function show(Project $customerProject): View
|
||||
{
|
||||
$this->authorize('view', $customerProject);
|
||||
$customerProject->load(['keywords' => fn ($q) => $q->active()->orderBy('phrase'), 'keywords.serpChecks' => fn ($q) => $q->where('status', 'completed')->latest('checked_at')->with(['results' => fn ($r) => $r->where('is_project_domain', true)->orderBy('position')])]);
|
||||
$rows = $customerProject->keywords->map(function ($keyword) { $checks=$keyword->serpChecks->filter(fn($c)=>$c->results->isNotEmpty())->values();$current=$checks->get(0);$previous=$checks->get(1);$rank=$current?->results->min('position');$prior=$previous?->results->min('position');return ['current'=>$rank,'change'=>($rank&&$prior)?$prior-$rank:null,'checked_at'=>$current?->checked_at]; });
|
||||
$measured=$rows->whereNotNull('current');
|
||||
return view('projects.show', ['project'=>$customerProject,'summary'=>['keywords'=>$customerProject->keywords->count(),'last_check'=>$rows->max('checked_at'),'average'=>$measured->isNotEmpty()?round($measured->avg('current'),1):null,'top10'=>$measured->where('current','<=',10)->count(),'winners'=>$rows->where('change','>',0)->count(),'losers'=>$rows->where('change','<',0)->count()]]);
|
||||
}
|
||||
|
||||
public function edit(Project $customerProject): View
|
||||
{
|
||||
$this->authorize('update', $customerProject);
|
||||
return view('projects.edit', ['project' => $customerProject]);
|
||||
}
|
||||
|
||||
public function update(UpdateProjectRequest $request, Project $customerProject, ProjectUrlNormalizer $normalizer): RedirectResponse
|
||||
{
|
||||
$normalized = $normalizer->normalize($request->string('website_url')->toString());
|
||||
try {
|
||||
$customerProject->update([
|
||||
'name' => $request->string('name')->trim()->toString(),
|
||||
'domain' => $normalized->domain,
|
||||
'url' => $normalized->url,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
$this->throwDuplicateValidation($exception);
|
||||
}
|
||||
return redirect()->route('projects.show', $customerProject)->with('status', 'Proje güncellendi.');
|
||||
}
|
||||
|
||||
public function archive(Project $customerProject): RedirectResponse
|
||||
{
|
||||
$this->authorize('archive', $customerProject);
|
||||
$customerProject->archive();
|
||||
return redirect()->route('projects.index')->with('status', 'Proje arşivlendi.');
|
||||
}
|
||||
|
||||
public function restore(Project $customerProject): RedirectResponse
|
||||
{
|
||||
$this->authorize('restore', $customerProject);
|
||||
$customerProject->restoreProject();
|
||||
return redirect()->route('projects.show', $customerProject)->with('status', 'Proje yeniden etkinleştirildi.');
|
||||
}
|
||||
|
||||
private function throwDuplicateValidation(QueryException $exception): never
|
||||
{
|
||||
if (! in_array((string) $exception->getCode(), ['23000', '19'], true)) throw $exception;
|
||||
throw ValidationException::withMessages(['website_url' => 'Bu alan adı hesabınızda zaten kayıtlı.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Http\Requests\RankingHistoryRequest;use App\Models\{Keyword,SerpCheck};use App\Services\RankingHistoryService;use App\Support\KeywordTargetOptions;use Illuminate\Http\Request;use Illuminate\View\View;
|
||||
class RankingHistoryController extends Controller{
|
||||
public function index(RankingHistoryRequest $r,RankingHistoryService $service):View{$projects=$r->user()->projects()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->with('project:id,ulid,name,domain')->orderBy('phrase')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}$selected=collect($r->input('keywords',[]));if($selected->isNotEmpty()){abort_if($selected->diff($keywords->pluck('ulid'))->isNotEmpty(),404);$keywords=$keywords->whereIn('ulid',$selected)->values();}foreach(['search_engine'=>'engine','country_code'=>'country','language_code'=>'language','device'=>'device'] as $column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();$checks=$service->checksFor($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay());$rows=$service->rows($checks);if($r->filled('movement'))$rows=$rows->where('movement',$r->input('movement'))->values();$page=max(1,(int)$r->input('page',1));$perPage=25;$paginator=new \Illuminate\Pagination\LengthAwarePaginator($rows->forPage($page,$perPage)->values(),$rows->count(),$perPage,$page,['path'=>$r->url(),'query'=>$r->query()]);$chart=$rows->whereNotNull('rank')->sortBy(fn($x)=>$x['check']->checked_at)->groupBy(fn($x)=>$x['keyword']->ulid)->take(10)->map(fn($series)=>['keyword'=>$series->first()['keyword']->phrase,'project'=>$series->first()['project']->name,'points'=>$series->map(fn($x)=>['date'=>$x['check']->checked_at->toIso8601String(),'rank'=>$x['rank'],'check'=>$x['check']->ulid])->values()])->values();return view('rankings.index',['projects'=>$projects,'keywords'=>$keywords,'rows'=>$paginator,'chart'=>$chart,'summary'=>$service->summary($rows,$keywords->count()),'distribution'=>$service->distribution($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}
|
||||
public function keyword(Request $r,Keyword $keyword,RankingHistoryService $service):View{$this->ownedKeyword($r,$keyword);$from=now()->subYears(2);$checks=$service->checksFor(collect([$keyword->id]),$from,now());$rows=$service->rows($checks);$ranked=$rows->whereNotNull('rank');return view('rankings.keyword',['keyword'=>$keyword->load('project'),'rows'=>$rows,'metrics'=>['current'=>$ranked->first()['rank']??null,'previous'=>$ranked->skip(1)->first()['rank']??null,'best'=>$ranked->min('rank'),'worst'=>$ranked->max('rank'),'count'=>$ranked->count(),'top10'=>$ranked->count()?round($ranked->where('rank','<=',10)->count()/$ranked->count()*100):null],'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}
|
||||
public function check(Request $r,SerpCheck $serpCheck,RankingHistoryService $service):View{abort_unless($serpCheck->keyword()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->exists(),404);$serpCheck->load(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->orderBy('position')->limit(10)]);return view('rankings.check',['check'=>$serpCheck,'rank'=>$serpCheck->results->where('is_project_domain',true)->min('position')]);}
|
||||
private function ownedKeyword(Request $r,Keyword $keyword):void{abort_unless($keyword->project()->where('user_id',$r->user()->id)->exists(),404);}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Enums\{CheckFrequency,SerpCheckStatus};use App\Models\{Keyword,Project,SerpCheck};use App\Services\{NextCheckAtCalculator,RankingHistoryService,SerpCheckCreator};use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Validation\Rule;use Illuminate\View\View;
|
||||
class SerpCheckController extends Controller{
|
||||
public function customerIndex(Request $r,RankingHistoryService $ranking):View{$this->authorize('viewAny',SerpCheck::class);$projects=$r->user()->projects()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->with('project:id,ulid,name')->orderBy('phrase')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}if($r->filled('keyword')){abort_unless($keywords->contains('ulid',$r->input('keyword')),404);$keywords=$keywords->where('ulid',$r->input('keyword'))->values();}$q=SerpCheck::query()->whereIn('keyword_id',$keywords->pluck('id'))->with(['keyword.project','results'=>fn($x)=>$x->where('result_type','organic')->where('is_project_domain',true)->orderBy('position')]);if($r->filled('status')){abort_unless(in_array($r->input('status'),array_column(SerpCheckStatus::cases(),'value'),true),422);$q->where('status',$r->input('status'));}if($r->filled('device')){$r->validate(['device'=>[Rule::in(['desktop','mobile'])]]);$q->where('device',$r->input('device'));}if($r->filled('from'))$q->whereDate('created_at','>=',$r->date('from'));if($r->filled('to'))$q->whereDate('created_at','<=',$r->date('to'));if($search=trim($r->string('q')->toString()))$q->where('phrase','like','%'.addcslashes($search,'%_').'%');$all=(clone $q)->get();$summary=['total'=>$all->count(),'pending'=>$all->where('status',SerpCheckStatus::Pending)->count(),'running'=>$all->where('status',SerpCheckStatus::Running)->count(),'completed'=>$all->where('status',SerpCheckStatus::Completed)->count(),'failed'=>$all->where('status',SerpCheckStatus::Failed)->count(),'last24'=>$all->where('created_at','>=',now()->subDay())->count()];$checks=$q->latest('created_at')->paginate(25)->withQueryString();$checks->getCollection()->each(fn($check)=>$check->setAttribute('detected_rank',$check->status===SerpCheckStatus::Completed?$ranking->rank($check):null));return view('serp-checks.customer-index',compact('projects','keywords','checks','summary'));}
|
||||
public function customerShow(Request $r,SerpCheck $serpCheck,RankingHistoryService $ranking):View{abort_unless($serpCheck->keyword()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->exists(),404);$serpCheck->load(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->orderBy('position')]);$previous=SerpCheck::query()->where('keyword_id',$serpCheck->keyword_id)->where('status',SerpCheckStatus::Completed)->where('checked_at','<',$serpCheck->checked_at)->latest('checked_at')->with(['results'=>fn($q)=>$q->where('result_type','organic')->where('is_project_domain',true)])->first();$rank=$serpCheck->status===SerpCheckStatus::Completed?$serpCheck->results->where('is_project_domain',true)->min('position'):null;$prior=$previous?$ranking->rank($previous):null;return view('serp-checks.customer-show',['check'=>$serpCheck,'rank'=>$rank,'previous'=>$prior,'change'=>$rank&&$prior?$prior-$rank:null]);}
|
||||
public function customerStore(Request $r,Keyword $keyword,SerpCheckCreator $creator):RedirectResponse{abort_unless($keyword->project()->where('user_id',$r->user()->id)->exists(),404);$this->authorize('create',[SerpCheck::class,$keyword]);try{$check=$creator->create($r->user(),$keyword);return redirect()->route('serp-checks.show',$check)->with('status',__('serp_checks.requested'));}catch(\Illuminate\Validation\ValidationException){return back()->withErrors(['serp'=>__('serp_checks.unavailable')]);}}
|
||||
public function index(Project $customerProject,Keyword $customerKeyword):View{$this->authorize('viewAny',SerpCheck::class);$checks=$customerKeyword->serpChecks()->latest('id')->paginate(15);return view('serp-checks.index',['project'=>$customerProject,'keyword'=>$customerKeyword,'checks'=>$checks]);}
|
||||
public function store(Request $r,Project $customerProject,Keyword $customerKeyword,SerpCheckCreator $creator):RedirectResponse{$this->authorize('create',[SerpCheck::class,$customerKeyword]);$check=$creator->create($r->user(),$customerKeyword);return redirect()->route('projects.keywords.serp-checks.show',[$customerProject,$customerKeyword,$check])->with('status',__('serp_checks.requested'));}
|
||||
public function show(Project $customerProject,Keyword $customerKeyword,SerpCheck $customerSerpCheck):View{$this->authorize('view',$customerSerpCheck);$customerSerpCheck->load(['results'=>fn($q)=>$q->orderBy('position')]);$best=$customerSerpCheck->status===SerpCheckStatus::Completed?$customerSerpCheck->results->where('is_project_domain',true)->min('position'):null;return view('serp-checks.show',['project'=>$customerProject,'keyword'=>$customerKeyword,'check'=>$customerSerpCheck,'best'=>$best]);}
|
||||
public function tracking(Request $r,Project $customerProject,Keyword $customerKeyword,NextCheckAtCalculator $next):RedirectResponse{$this->authorize('update',$customerKeyword);$data=$r->validate(['tracking_enabled'=>['nullable','boolean'],'check_frequency'=>['required',Rule::enum(CheckFrequency::class)]]);$enabled=$r->boolean('tracking_enabled');$customerKeyword->forceFill(['tracking_enabled'=>$enabled,'check_frequency'=>$data['check_frequency'],'next_check_at'=>$enabled?$next->calculate($data['check_frequency']):null])->save();return back()->with('status',__('serp_checks.tracking_updated'));}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;use App\Models\UserSetting;use App\Services\ProfileAuditLogger;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Validation\Rule;use Illuminate\View\View;
|
||||
class SettingsController extends Controller{private function setting(Request$r):UserSetting{return$r->user()->setting()->firstOrCreate([],UserSetting::defaults());}public function show(Request$r):View{$setting=$this->setting($r);$projects=$r->user()->projects()->active()->orderBy('name')->get();return view('settings.show',compact('setting','projects'));}public function appearance(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['locale'=>['required',Rule::in(['tr','en'])],'theme'=>['required',Rule::in(['light','dark','system'])],'reduced_motion'=>['nullable','boolean']]);$s=$this->setting($r);$before=$s->only(['theme','reduced_motion']);$s->update(['theme'=>$data['theme'],'reduced_motion'=>$r->boolean('reduced_motion')]);$r->user()->forceFill(['locale'=>$data['locale']])->save();$r->session()->put('locale',$data['locale']);$a->record('settings_appearance_updated',$r->user(),$before,$s->only(['theme','reduced_motion']));return back()->with('status',__('settings.saved'));}public function regional(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['timezone'=>['required',Rule::in(\DateTimeZone::listIdentifiers())],'date_format'=>['required',Rule::in(['DD.MM.YYYY','YYYY-MM-DD','MM/DD/YYYY'])],'time_format'=>['required',Rule::in(['24','12'])]]);return$this->save($r,$a,'settings_regional_updated',$data);}public function dashboard(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['default_report_range'=>['required',Rule::in(['7_days','30_days','90_days','this_month'])],'default_project'=>['nullable','string'],'per_page'=>['required',Rule::in([10,25,50,100])]]);$project=null;if($r->filled('default_project'))$project=$r->user()->projects()->active()->where('ulid',$r->input('default_project'))->firstOrFail();return$this->save($r,$a,'settings_dashboard_updated',['default_report_range'=>$data['default_report_range'],'default_project_id'=>$project?->id,'per_page'=>(int)$data['per_page']]);}public function notifications(Request$r,ProfileAuditLogger$a):RedirectResponse{$r->validate(['in_app_notifications'=>['nullable','boolean']]);return$this->save($r,$a,'settings_notifications_updated',['in_app_notifications'=>$r->boolean('in_app_notifications')]);}public function reset(Request$r,ProfileAuditLogger$a):RedirectResponse{$s=$this->setting($r);$before=$s->toArray();$s->update(UserSetting::defaults());$r->user()->forceFill(['locale'=>'tr'])->save();$r->session()->put('locale','tr');$a->record('settings_reset',$r->user(),$before,$s->fresh()->toArray());return back()->with('status',__('settings.reset_done'));}private function save(Request$r,ProfileAuditLogger$a,string$action,array$data):RedirectResponse{$s=$this->setting($r);$before=$s->only(array_keys($data));$s->update($data);$a->record($action,$r->user(),$before,$s->only(array_keys($data)));return back()->with('status',__('settings.saved'));}}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\RoleName;
|
||||
use App\Models\EarnApiToken;
|
||||
use App\Models\EarnTaskAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Kodular istemcisine cihazla eşleştirilmiş JSON görev akışı sağlar.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
class TestTaskController extends Controller
|
||||
{
|
||||
/**
|
||||
* Yapılandırılmış görevleri gerçek müşteri sahibiyle doğrulayıp token ve cihaza tekil olarak atar.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$allowedHosts = config('test_tasks.allowed_hosts', []);
|
||||
$tasks = config('test_tasks.tasks', []);
|
||||
$customerEmail = config('test_tasks.customer_email');
|
||||
$token = $request->attributes->get('earn_api_token');
|
||||
$deviceHash = $request->attributes->get('earn_device_hash');
|
||||
|
||||
if (! $token instanceof EarnApiToken || ! is_string($deviceHash) || ! is_string($customerEmail)) {
|
||||
throw new RuntimeException('Test task ownership is not configured.');
|
||||
}
|
||||
|
||||
$customer = User::query()->where('email', $customerEmail)->first();
|
||||
if (! $customer?->hasRole(RoleName::Customer)) {
|
||||
throw new RuntimeException('Test task customer is invalid.');
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach ($tasks as $task) {
|
||||
$url = $task['url'] ?? null;
|
||||
|
||||
if (! is_string($url) || parse_url($url, PHP_URL_SCHEME) !== 'https' || ! in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true)) {
|
||||
throw new RuntimeException('Test task configuration contains a URL outside the HTTPS allowlist.');
|
||||
}
|
||||
|
||||
// Görevi ilk istemciye atomik olarak kilitler.
|
||||
// Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
$assignment = DB::transaction(function () use ($task, $customer, $token, $deviceHash): EarnTaskAssignment {
|
||||
return EarnTaskAssignment::query()->lockForUpdate()->firstOrCreate(
|
||||
['task_id' => $task['task_id']],
|
||||
['user_id' => $customer->id, 'earn_member_id' => $token->tokenable_id,
|
||||
'personal_access_token_id' => $token->id, 'device_id_hash' => $deviceHash,
|
||||
'url' => $task['url'], 'duration_seconds' => $task['duration_seconds'], 'assigned_at' => now()],
|
||||
);
|
||||
}, 3);
|
||||
|
||||
if ($assignment->personal_access_token_id !== $token->id
|
||||
|| ! hash_equals($assignment->device_id_hash, $deviceHash)
|
||||
|| $assignment->result()->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = ['CID' => $assignment->user_id, 'task_id' => $assignment->task_id,
|
||||
'url' => $assignment->url, 'duration_seconds' => $assignment->duration_seconds,
|
||||
'completed' => false, 'responseCode' => null, 'error' => null];
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(['tasks' => $payload])
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Http\Requests\VisibilityReportRequest;use App\Models\Keyword;use App\Services\VisibilityReportService;use App\Support\KeywordTargetOptions;use Illuminate\View\View;
|
||||
class VisibilityReportController extends Controller{public function __invoke(VisibilityReportRequest $r,VisibilityReportService $service):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();$from=now()->parse($r->input('from'))->startOfDay();$to=now()->parse($r->input('to'))->endOfDay();$days=$from->diffInDays($to)+1;$previousFrom=$from->copy()->subDays($days);$previousTo=$from->copy()->subSecond();$checks=$service->measurements($keywords->pluck('id'),$from,$to);$previousChecks=$r->input('compare','previous')==='none'?collect():$service->measurements($keywords->pluck('id'),$previousFrom,$previousTo);$latest=$service->latest($checks);$previousLatest=$service->latest($previousChecks);$current=$service->score($latest);$previous=$service->score($previousLatest);$contributions=$service->contributions($latest,$previousLatest);$distribution=$service->distribution($latest,$keywords->count());$metrics=['current'=>$current,'previous'=>$previous,'change'=>$current!==null&&$previous!==null?round($current-$previous,2):null,'measured'=>$latest->count(),'top10'=>$distribution['top3']+$distribution['4_10'],'unranked'=>$distribution['unranked']];return view('reports.visibility',compact('projects','keywords','metrics','distribution')+['trend'=>$service->trend($checks),'previousTrend'=>$service->trend($previousChecks),'winners'=>$contributions->where('delta','>',0)->sortByDesc('delta')->take(5),'losers'=>$contributions->where('delta','<',0)->sortBy('delta')->take(5),'coverage'=>[$latest->count(),$keywords->count()],'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages(),'formulaVersion'=>VisibilityReportService::FORMULA_VERSION]);}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;use App\Http\Requests\VisibilityReportRequest;use App\Models\Keyword;use App\Services\WinnersReportService;use App\Support\KeywordTargetOptions;use Illuminate\Pagination\LengthAwarePaginator;use Illuminate\View\View;
|
||||
class WinnersReportController extends Controller{public function __invoke(VisibilityReportRequest$r,WinnersReportService$s):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$p=$projects->firstWhere('ulid',$r->input('project'));abort_unless($p,404);$keywords=$keywords->where('project_id',$p->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();if($search=trim($r->string('q')->toString()))$keywords=$keywords->filter(fn($k)=>str_contains(mb_strtolower($k->phrase),mb_strtolower($search)))->values();$rows=$s->winners($s->rows($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay()));if($r->filled('min_gained'))$rows=$rows->filter(fn($x)=>($x['gained']??0)>=(int)$r->input('min_gained'))->values();$sort=in_array($r->input('sort'),['current','gained','contribution','checked_at'],true)?$r->input('sort'):'gained';$rows=$rows->sortByDesc($sort)->values();$page=max(1,(int)$r->input('page',1));$pager=new LengthAwarePaginator($rows->forPage($page,25)->values(),$rows->count(),25,$page,['path'=>$r->url(),'query'=>$r->query()]);return view('reports.winners',['projects'=>$projects,'rows'=>$pager,'summary'=>$s->summary($rows),'distribution'=>$s->distribution($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\PortalSession;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateAdminPortal
|
||||
{
|
||||
public function __construct(private readonly PortalSession $sessions) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$guard = Filament::auth();
|
||||
if (! $guard->check()) {
|
||||
return redirect()->guest(Filament::getLoginUrl());
|
||||
}
|
||||
|
||||
$user = $guard->user();
|
||||
if (! $user->canAccessAdminPortal()) {
|
||||
$this->sessions->terminate($request);
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => __('auth.portal_denied')]);
|
||||
}
|
||||
|
||||
Auth::shouldUse(Filament::getAuthGuard());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\EarnApiTokenStatus;
|
||||
use App\Models\EarnApiToken;
|
||||
use App\Models\EarnMember;
|
||||
use App\Services\EarnApiAccessLogger;
|
||||
use App\Services\EarnDeviceIdentity;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateEarnApiToken
|
||||
{
|
||||
public function __construct(private EarnDeviceIdentity $devices, private EarnApiAccessLogger $logs) {}
|
||||
|
||||
/**
|
||||
* Earn token yaşam döngüsünü, yeteneğini, cihaz bağını ve moda özel hız sınırını doğrular.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $ability, string $mode = 'tasks'): Response
|
||||
{
|
||||
$plain = $request->bearerToken();
|
||||
if (! is_string($plain) || $plain === '') {
|
||||
return $this->deny($request, 'TOKEN_MISSING', 401);
|
||||
}
|
||||
$token = EarnApiToken::findToken($plain);
|
||||
if (! $token instanceof EarnApiToken || ! $token->tokenable instanceof EarnMember) {
|
||||
return $this->deny($request, 'TOKEN_INVALID', 401);
|
||||
}
|
||||
$request->attributes->set('earn_api_token', $token);
|
||||
$code = match ($token->status()) {
|
||||
EarnApiTokenStatus::Inactive => 'TOKEN_INACTIVE', EarnApiTokenStatus::Expired => 'TOKEN_EXPIRED',
|
||||
EarnApiTokenStatus::Revoked => 'TOKEN_REVOKED', default => null,
|
||||
};
|
||||
if ($code) {
|
||||
return $this->deny($request, $code, 401, 'token_'.strtolower(substr($code, 6)));
|
||||
}
|
||||
$member = $token->tokenable;
|
||||
if ($member->trashed() || $member->status->value !== 'active') {
|
||||
return $this->deny($request, 'MEMBER_INACTIVE', 403, 'member_inactive');
|
||||
}
|
||||
if (! $member->hasVerifiedEmail()) {
|
||||
return $this->deny($request, 'EMAIL_NOT_VERIFIED', 403);
|
||||
}
|
||||
if (! $token->can($ability)) {
|
||||
return $this->deny($request, 'TOKEN_INVALID', 401);
|
||||
}
|
||||
$deviceId = $request->header('X-Device-ID');
|
||||
if (! is_string($deviceId)) {
|
||||
return $this->deny($request, 'DEVICE_ID_MISSING', 422);
|
||||
}
|
||||
$deviceId = $this->devices->normalize($deviceId);
|
||||
if (! $this->devices->valid($deviceId)) {
|
||||
return $this->deny($request, 'DEVICE_ID_INVALID', 422);
|
||||
}
|
||||
$hash = $this->devices->hash($deviceId);
|
||||
$preview = $this->devices->preview($deviceId);
|
||||
$request->attributes->set('earn_device_hash', $hash);
|
||||
$request->attributes->set('earn_device_preview', $preview);
|
||||
$limit = (int) config(match ($mode) {
|
||||
'bind' => 'earn.api.bind_per_minute',
|
||||
'results' => 'earn.api.task_results_per_minute',
|
||||
default => 'earn.api.tasks_per_minute',
|
||||
});
|
||||
$rateKey = 'earn-api:'.$mode.':'.hash('sha256', $token->id.'|'.$hash.'|'.$request->ip());
|
||||
if (RateLimiter::tooManyAttempts($rateKey, $limit)) {
|
||||
$this->logs->record($request, $mode === 'results' ? 'task_result_rate_limited' : 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview);
|
||||
|
||||
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($rateKey));
|
||||
}
|
||||
RateLimiter::hit($rateKey, 60);
|
||||
if (in_array($mode, ['tasks', 'results'], true) && $token->device_id_hash === null) {
|
||||
return $this->deny($request, 'DEVICE_NOT_BOUND', 409, 'device_not_bound', $hash, $preview);
|
||||
}
|
||||
if (in_array($mode, ['tasks', 'results'], true) && ! hash_equals($token->device_id_hash, $hash)) {
|
||||
$failedKey = 'earn-api:failed-device:'.hash('sha256', $token->id.'|'.$request->ip());
|
||||
$failedLimit = (int) config('earn.api.failed_device_per_minute');
|
||||
if (RateLimiter::tooManyAttempts($failedKey, $failedLimit)) {
|
||||
$this->logs->record($request, 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview);
|
||||
|
||||
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($failedKey));
|
||||
}
|
||||
RateLimiter::hit($failedKey, 60);
|
||||
$token->forceFill(['device_mismatch_seen' => true])->saveQuietly();
|
||||
|
||||
return $this->deny($request, 'DEVICE_MISMATCH', 409, 'device_mismatch', $hash, $preview);
|
||||
}
|
||||
$member->withAccessToken($token);
|
||||
$request->setUserResolver(fn () => $member);
|
||||
$response = $next($request);
|
||||
if (in_array($mode, ['tasks', 'results'], true) && $response->isSuccessful()) {
|
||||
if ($token->last_ip !== null && $token->last_ip !== $request->ip()) {
|
||||
$this->logs->record($request, 'ip_changed', $response->getStatusCode(), null, $token, $hash, $preview);
|
||||
}
|
||||
$token->forceFill(['last_used_at' => now(), 'last_seen_at' => now(), 'last_ip' => $request->ip()])->saveQuietly();
|
||||
$this->logs->record($request, $mode === 'results' ? 'task_results_access_granted' : 'tasks_access_granted', $response->getStatusCode(), null, $token, $hash, $preview);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function deny(Request $request, string $code, int $status, string $event = 'tasks_access_denied', ?string $hash = null, ?string $preview = null): JsonResponse
|
||||
{
|
||||
$this->logs->record($request, $event, $status, $code, null, $hash, $preview);
|
||||
|
||||
return $this->error($code, $status);
|
||||
}
|
||||
|
||||
private function error(string $code, int $status): JsonResponse
|
||||
{
|
||||
return response()->json(['success' => false, 'error' => ['code' => $code, 'message' => __('earn_api.'.$code)]], $status)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\PortalSession;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdminPortalAccess
|
||||
{
|
||||
public function __construct(private readonly PortalSession $sessions) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user || $user->canAccessAdminPortal()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$this->sessions->terminate($request);
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => __('auth.portal_denied')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\PortalSession;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCustomerPortalAccess
|
||||
{
|
||||
public function __construct(private readonly PortalSession $sessions) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user?->canAccessCustomerPortal()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$admin = $user?->canAccessAdminPortal() ?? false;
|
||||
$this->sessions->terminate($request);
|
||||
|
||||
return redirect()->to($admin ? '/admin/login' : route('login'))
|
||||
->withErrors(['email' => __('auth.portal_denied')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureEarnEmailIsVerified
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! auth('earn')->user()?->hasVerifiedEmail()) {
|
||||
return redirect()->route('earn.verification.notice');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\EarnMemberStatus;
|
||||
use App\Models\EarnMember;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureEarnMemberActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$member = auth('earn')->user();
|
||||
|
||||
if (! $member instanceof EarnMember || $member->trashed() || $member->status !== EarnMemberStatus::Active) {
|
||||
auth('earn')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('earn.login')->withErrors([
|
||||
'email' => __('earn_portal.account_unavailable'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && ! $request->user()->isActive()) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => __('auth.inactive')]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
public const SUPPORTED = ['tr', 'en'];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$userLocale = $request->user()?->locale;
|
||||
$storedLocale = $request->session()->get('locale', $request->cookie('locale'));
|
||||
$locale = in_array($userLocale, self::SUPPORTED, true)
|
||||
? $userLocale
|
||||
: (in_array($storedLocale, self::SUPPORTED, true) ? $storedLocale : config('app.locale', 'tr'));
|
||||
|
||||
if (! in_array($locale, self::SUPPORTED, true)) {
|
||||
$locale = 'tr';
|
||||
}
|
||||
|
||||
App::setLocale($locale);
|
||||
$request->session()->put('locale', $locale);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;use Illuminate\Validation\Rule;
|
||||
class LosersReportRequest extends VisibilityReportRequest{public function rules():array{return parent::rules()+['loss_type'=>['nullable',Rule::in(['top3','top10','top100','dropped_out'])],'min_lost'=>['nullable','integer','min:1','max:100'],'min_visibility_loss'=>['nullable','numeric','min:0','max:100'],'rank_range'=>['nullable',Rule::in(['top3','top10','top20','top100','unranked'])]];}}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;
|
||||
use App\Enums\{KeywordDevice,SearchEngine};use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\Rule;
|
||||
class RankingHistoryRequest extends FormRequest{
|
||||
public function authorize():bool{return $this->user()!==null;}
|
||||
protected function prepareForValidation():void{$range=$this->input('range','30d');$now=now();[$from,$to]=match($range){'7d'=>[$now->copy()->subDays(6)->startOfDay(),$now->copy()->endOfDay()],'90d'=>[$now->copy()->subDays(89)->startOfDay(),$now->copy()->endOfDay()],'this_month'=>[$now->copy()->startOfMonth(),$now->copy()->endOfDay()],'last_month'=>[$now->copy()->subMonthNoOverflow()->startOfMonth(),$now->copy()->subMonthNoOverflow()->endOfMonth()],'custom'=>[$this->input('from'),$this->input('to')],default=>[$now->copy()->subDays(29)->startOfDay(),$now->copy()->endOfDay()]};if($range!=='custom')$this->merge(['range'=>in_array($range,['7d','30d','90d','this_month','last_month'],true)?$range:'30d','from'=>$from->toDateString(),'to'=>$to->toDateString()]);}
|
||||
public function rules():array{return ['range'=>['required',Rule::in(['7d','30d','90d','this_month','last_month','custom'])],'from'=>['required','date','before_or_equal:to','before_or_equal:today','after_or_equal:'.now()->subYears(2)->toDateString()],'to'=>['required','date','after_or_equal:from','before_or_equal:today'],'project'=>['nullable','string'],'keywords'=>['nullable','array','max:10'],'keywords.*'=>['string'],'engine'=>['nullable',Rule::enum(SearchEngine::class)],'country'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['nullable',Rule::enum(KeywordDevice::class)],'movement'=>['nullable',Rule::in(['winner','loser','unchanged','new','unranked'])]];}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;use App\Services\ProjectUrlNormalizer;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\ValidationException;
|
||||
class StoreCompetitorRequest extends FormRequest{public function authorize():bool{return$this->user()!==null;}protected function prepareForValidation():void{try{$n=app(ProjectUrlNormalizer::class)->normalize((string)$this->input('domain'));$this->merge(['normalized_domain'=>$n->domain,'domain'=>$n->domain,'name'=>trim((string)$this->input('name'))?:$n->domain]);}catch(\InvalidArgumentException$e){throw ValidationException::withMessages(['domain'=>__('competitors.invalid_domain')]);}}public function rules():array{return['name'=>['required','string','max:150'],'domain'=>['required','string','max:253'],'normalized_domain'=>['required','string','max:253']];}}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreEarnTaskResultsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Token ve cihaz yetkilendirmesi middleware'de tamamlandığı için isteğin doğrulanmasına izin verir.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Görev sonucu listesinin tür, boyut ve HTTP durum alanlarını katı biçimde doğrular.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tasks' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'tasks.*.CID' => ['required', 'integer', 'min:1'],
|
||||
'tasks.*.task_id' => ['required', 'string', 'max:64'],
|
||||
'tasks.*.completed' => ['required'],
|
||||
'tasks.*.responseCode' => ['nullable', 'integer', 'between:100,599'],
|
||||
'tasks.*.error' => ['nullable', 'string', 'max:'.config('earn.api.task_result_max_error_length')],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean ve başarı alanlarının sessiz tür dönüşümü olmadan tutarlı olmasını denetler.
|
||||
*
|
||||
* @return array<int, callable(Validator): void>
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function after(): array
|
||||
{
|
||||
// Tür dönüşümü yapmadan kayıtlar arası tutarlılığı denetler.
|
||||
// Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
return [function (Validator $validator): void {
|
||||
foreach ((array) $this->input('tasks', []) as $index => $task) {
|
||||
if (! is_int($task['CID'] ?? null)) {
|
||||
$validator->errors()->add("tasks.$index.CID", 'The CID field must be a JSON integer.');
|
||||
}
|
||||
if (array_key_exists('responseCode', $task) && $task['responseCode'] !== null && ! is_int($task['responseCode'])) {
|
||||
$validator->errors()->add("tasks.$index.responseCode", 'The responseCode field must be a JSON integer or null.');
|
||||
}
|
||||
if (! is_bool($task['completed'] ?? null)) {
|
||||
$validator->errors()->add("tasks.$index.completed", 'The completed field must be a JSON boolean.');
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($task['completed'] === true && (($task['error'] ?? null) !== null
|
||||
|| ! isset($task['responseCode']) || $task['responseCode'] < 200 || $task['responseCode'] > 399)) {
|
||||
$validator->errors()->add("tasks.$index.completed", 'A completed task requires a 2xx/3xx response and no error.');
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;
|
||||
use App\Enums\{KeywordDevice,SearchEngine};use App\Models\{Keyword,Project};use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\{Rule,Validator};
|
||||
class StoreKeywordRequest extends FormRequest{protected function prepareForValidation():void{if(!$this->has('phrases')&&$this->has('phrase'))$this->merge(['phrases'=>$this->input('phrase')]);}public function project():?Project{return $this->route('customerProject')?:$this->user()?->projects()->active()->where('ulid',$this->input('project'))->first();}public function authorize():bool{$p=$this->project();return $p&&($this->user()?->can('create',[Keyword::class,$p])??false);}public function rules():array{$legacy=$this->has('phrase');return ['project'=>['nullable','string'],'phrases'=>$legacy?['nullable','string']:['required','string','max:20000'],'phrase'=>$legacy?['required','string','max:190']:['nullable'],'search_engine'=>['required',Rule::enum(SearchEngine::class)],'country_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['required',Rule::enum(KeywordDevice::class)]];}public function withValidator(Validator $v):void{$v->after(function(Validator $v){if(!$this->project())$v->errors()->add('project',__('keywords.validation.project'));if($this->filled(['country_code','language_code'])&&!KeywordTargetOptions::supports($this->input('country_code'),$this->input('language_code')))$v->errors()->add('language_code',__('keywords.validation.target_pair'));$lines=preg_split('/\R/u',(string)$this->input('phrases'))?:[];if(count(array_filter($lines,fn($x)=>trim($x)!==''))>100)$v->errors()->add($this->has('phrase')?'phrase':'phrases',__('keywords.validation.limit',['count'=>100]));});}}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectUrlNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class StoreProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return $this->user()?->can('create', Project::class) ?? false; }
|
||||
public function rules(): array { return ['name' => ['required', 'string', 'max:150'], 'website_url' => ['required', 'string', 'max:2048']]; }
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
if ($validator->errors()->has('website_url')) return;
|
||||
try {
|
||||
$normalized = app(ProjectUrlNormalizer::class)->normalize((string) $this->input('website_url'));
|
||||
if ($this->user()->projects()->where('domain', $normalized->domain)->exists()) {
|
||||
$validator->errors()->add('website_url', 'Bu alan adı hesabınızda zaten kayıtlı.');
|
||||
}
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$validator->errors()->add('website_url', $exception->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;
|
||||
use App\Enums\{KeywordDevice,KeywordStatus};use App\Models\Keyword;use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\{Rule,Validator};
|
||||
class UpdateKeywordRequest extends FormRequest{public function keyword():?Keyword{return $this->route('customerKeyword')?:$this->route('keyword');}public function authorize():bool{$k=$this->keyword();return $k&&$k->archived_at===null&&$k->status!==KeywordStatus::Archived&&($this->user()?->can('update',$k)??false);}public function rules():array{return ['phrase'=>['required','string','max:190'],'country_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['required',Rule::enum(KeywordDevice::class)],'status'=>['required',Rule::in([KeywordStatus::Active->value,KeywordStatus::Inactive->value])]];}public function withValidator(Validator $v):void{$v->after(function(Validator $v){if($this->filled(['country_code','language_code'])&&!KeywordTargetOptions::supports($this->input('country_code'),$this->input('language_code')))$v->errors()->add('language_code',__('keywords.validation.target_pair'));});}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user