Initialer Laravel Commit für BetiX
This commit is contained in:
56
app/Http/Controllers/Admin/GeoBlockController.php
Normal file
56
app/Http/Controllers/Admin/GeoBlockController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class GeoBlockController extends Controller
|
||||
{
|
||||
private const KEY = 'geo.settings';
|
||||
|
||||
private array $defaults = [
|
||||
'enabled' => false,
|
||||
'blocked_countries' => [],
|
||||
'allowed_countries' => [],
|
||||
'mode' => 'blacklist', // 'blacklist' or 'whitelist'
|
||||
'vpn_block' => false,
|
||||
'vpn_provider' => 'none', // 'none', 'ipqualityscore', 'proxycheck'
|
||||
'vpn_api_key' => '',
|
||||
'block_message' => 'This service is not available in your region.',
|
||||
'redirect_url' => '',
|
||||
];
|
||||
|
||||
public function show()
|
||||
{
|
||||
$saved = AppSetting::get(self::KEY, []);
|
||||
$settings = array_merge($this->defaults, is_array($saved) ? $saved : []);
|
||||
|
||||
return Inertia::render('Admin/GeoBlock', [
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'enabled' => 'boolean',
|
||||
'mode' => 'required|in:blacklist,whitelist',
|
||||
'blocked_countries' => 'array',
|
||||
'blocked_countries.*' => 'string|size:2',
|
||||
'allowed_countries' => 'array',
|
||||
'allowed_countries.*' => 'string|size:2',
|
||||
'vpn_block' => 'boolean',
|
||||
'vpn_provider' => 'required|in:none,ipqualityscore,proxycheck',
|
||||
'vpn_api_key' => 'nullable|string|max:200',
|
||||
'block_message' => 'required|string|max:500',
|
||||
'redirect_url' => 'nullable|url|max:500',
|
||||
]);
|
||||
|
||||
AppSetting::put(self::KEY, $data);
|
||||
|
||||
return back()->with('success', 'GeoBlock settings saved.');
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/Admin/PaymentsSettingsController.php
Normal file
130
app/Http/Controllers/Admin/PaymentsSettingsController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppSetting;
|
||||
use App\Services\DepositService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PaymentsSettingsController extends Controller
|
||||
{
|
||||
public function __construct(private readonly DepositService $deposits)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/payments/settings
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||
|
||||
$settings = $this->deposits->getSettings();
|
||||
|
||||
return Inertia::render('Admin/PaymentsSettings', [
|
||||
'settings' => $settings,
|
||||
'defaults' => [
|
||||
'commonCurrencies' => ['BTC','ETH','LTC','SOL','USDT_ERC20','USDT_TRC20','BCH','DOGE'],
|
||||
'modes' => ['live','sandbox'],
|
||||
'addressModes' => ['per_payment','per_user'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/payments/settings
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'mode' => ['required','in:live,sandbox'],
|
||||
'api_key' => ['nullable','string','max:200'],
|
||||
'ipn_secret' => ['nullable','string','max:200'],
|
||||
'enabled_currencies' => ['required','array','min:1'],
|
||||
'enabled_currencies.*' => ['string','max:32'],
|
||||
'global_min_usd' => ['required','numeric','min:0'],
|
||||
'global_max_usd' => ['required','numeric','gt:global_min_usd'],
|
||||
'btx_per_usd' => ['required','numeric','min:0.00000001'],
|
||||
'per_currency_overrides' => ['sometimes','array'],
|
||||
'per_currency_overrides.*.min_usd' => ['nullable','numeric','min:0'],
|
||||
'per_currency_overrides.*.max_usd' => ['nullable','numeric'],
|
||||
'per_currency_overrides.*.btx_per_usd' => ['nullable','numeric','min:0.00000001'],
|
||||
'success_url' => ['required','string','max:255'],
|
||||
'cancel_url' => ['required','string','max:255'],
|
||||
'address_mode' => ['required','in:per_payment,per_user'],
|
||||
]);
|
||||
|
||||
// Normalize overrides structure as map keyed by currency
|
||||
$overrides = [];
|
||||
if (!empty($data['per_currency_overrides']) && is_array($data['per_currency_overrides'])) {
|
||||
foreach ($data['per_currency_overrides'] as $cur => $vals) {
|
||||
if (is_array($vals)) {
|
||||
$entry = [];
|
||||
if (array_key_exists('min_usd', $vals) && $vals['min_usd'] !== null) $entry['min_usd'] = (float) $vals['min_usd'];
|
||||
if (array_key_exists('max_usd', $vals) && $vals['max_usd'] !== null) $entry['max_usd'] = (float) $vals['max_usd'];
|
||||
if (array_key_exists('btx_per_usd', $vals) && $vals['btx_per_usd'] !== null) $entry['btx_per_usd'] = (float) $vals['btx_per_usd'];
|
||||
if (!empty($entry)) {
|
||||
$overrides[strtoupper($cur)] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing api_key/ipn_secret if not re-submitted (masked fields)
|
||||
$existing = AppSetting::get('payments.nowpayments', []);
|
||||
$apiKey = $data['api_key'] ?? null;
|
||||
$ipnSecret = $data['ipn_secret'] ?? null;
|
||||
|
||||
$payload = [
|
||||
'mode' => $data['mode'],
|
||||
'api_key' => $apiKey ?: ($existing['api_key'] ?? ''),
|
||||
'ipn_secret' => $ipnSecret ?: ($existing['ipn_secret'] ?? ''),
|
||||
'enabled_currencies' => array_values(array_map('strtoupper', $data['enabled_currencies'])),
|
||||
'global_min_usd' => (float) $data['global_min_usd'],
|
||||
'global_max_usd' => (float) $data['global_max_usd'],
|
||||
'btx_per_usd' => (float) $data['btx_per_usd'],
|
||||
'per_currency_overrides' => $overrides,
|
||||
'success_url' => (string) $data['success_url'],
|
||||
'cancel_url' => (string) $data['cancel_url'],
|
||||
'address_mode' => (string) $data['address_mode'],
|
||||
];
|
||||
|
||||
AppSetting::put('payments.nowpayments', $payload);
|
||||
|
||||
return back()->with('success', 'Payment settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/payments/test
|
||||
*/
|
||||
public function test(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||
|
||||
$data = $request->validate(['api_key' => 'required|string|max:200']);
|
||||
|
||||
try {
|
||||
$res = Http::timeout(8)->withHeaders([
|
||||
'x-api-key' => $data['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->get('https://api.nowpayments.io/v1/status');
|
||||
|
||||
if ($res->ok()) {
|
||||
return response()->json(['ok' => true, 'message' => 'Verbindung erfolgreich! NOWPayments API erreichbar.']);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'API antwortet mit Status ' . $res->status() . '. API Key prüfen.'], 422);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'message' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/Admin/PromoAdminController.php
Normal file
130
app/Http/Controllers/Admin/PromoAdminController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PromoAdminController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
private function assertAdmin(): void
|
||||
{
|
||||
if (!Auth::check() || strtolower((string) Auth::user()->role) !== 'admin') {
|
||||
abort(403, 'Nur für Admins');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show simple admin page to manage promos (data from upstream)
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->assertAdmin();
|
||||
|
||||
$promos = [];
|
||||
try {
|
||||
$res = $this->client->get($request, '/admin/promos', ['per_page' => 20], retry: true);
|
||||
if ($res->successful()) {
|
||||
$j = $res->json() ?: [];
|
||||
$promos = $j['data'] ?? $j['promos'] ?? $j;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// show empty list with error message on page via flash if desired
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Promos', [
|
||||
'promos' => $promos,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new promo via upstream
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->assertAdmin();
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$data['code'] = strtoupper(trim($data['code']));
|
||||
$data['is_active'] = $data['is_active'] ?? true;
|
||||
|
||||
try {
|
||||
$res = $this->client->post($request, '/admin/promos', $data);
|
||||
if ($res->successful()) {
|
||||
return back()->with('success', 'Promo erstellt.');
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Ungültige Eingabe');
|
||||
return back()->withErrors(['promo' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['promo' => 'Service temporär nicht verfügbar']);
|
||||
}
|
||||
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing promo via upstream
|
||||
*/
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$this->assertAdmin();
|
||||
|
||||
$data = $this->validateData($request, $id);
|
||||
if (isset($data['code'])) {
|
||||
$data['code'] = strtoupper(trim($data['code']));
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $this->client->patch($request, "/admin/promos/{$id}", $data);
|
||||
if ($res->successful()) {
|
||||
return back()->with('success', 'Promo aktualisiert.');
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Ungültige Eingabe');
|
||||
return back()->withErrors(['promo' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['promo' => 'Service temporär nicht verfügbar']);
|
||||
}
|
||||
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateData(Request $request, ?int $ignoreId = null): array
|
||||
{
|
||||
$isUpdate = $request->isMethod('patch') || $request->isMethod('put');
|
||||
|
||||
$rules = [
|
||||
'code' => [($isUpdate ? 'sometimes' : 'required'), 'string', 'max:64'],
|
||||
'description' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'string', 'max:255'],
|
||||
'bonus_amount' => [($isUpdate ? 'sometimes' : 'required'), 'numeric', 'min:0'],
|
||||
'wager_multiplier' => [($isUpdate ? 'sometimes' : 'required'), 'integer', 'min:0', 'max:1000'],
|
||||
'per_user_limit' => [($isUpdate ? 'sometimes' : 'required'), 'integer', 'min:1', 'max:1000'],
|
||||
'global_limit' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'integer', 'min:1', 'max:1000000'],
|
||||
'starts_at' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'date'],
|
||||
'ends_at' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'min_deposit' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'numeric', 'min:0'],
|
||||
'bonus_expires_days' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'integer', 'min:1', 'max:365'],
|
||||
'is_active' => [($isUpdate ? 'sometimes' : 'nullable'), 'boolean'],
|
||||
];
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
}
|
||||
79
app/Http/Controllers/Admin/SiteSettingsController.php
Normal file
79
app/Http/Controllers/Admin/SiteSettingsController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SiteSettingsController extends Controller
|
||||
{
|
||||
private const KEY = 'site.settings';
|
||||
|
||||
private array $defaults = [
|
||||
'site_name' => 'BetiX Casino',
|
||||
'site_tagline' => 'Play. Win. Repeat.',
|
||||
'primary_color' => '#df006a',
|
||||
'logo_url' => '',
|
||||
'favicon_url' => '',
|
||||
'maintenance_mode' => false,
|
||||
'registration_open' => true,
|
||||
'min_deposit_usd' => 10,
|
||||
'max_deposit_usd' => 50000,
|
||||
'min_withdrawal_usd' => 20,
|
||||
'max_withdrawal_usd' => 100000,
|
||||
'max_bet_usd' => 5000,
|
||||
'house_edge_percent' => 1.0,
|
||||
'footer_text' => '',
|
||||
'support_email' => '',
|
||||
'terms_url' => '/terms',
|
||||
'privacy_url' => '/privacy',
|
||||
'currency_symbol' => 'BTX',
|
||||
];
|
||||
|
||||
public function show()
|
||||
{
|
||||
$saved = AppSetting::get(self::KEY, []);
|
||||
$settings = array_merge($this->defaults, is_array($saved) ? $saved : []);
|
||||
|
||||
return Inertia::render('Admin/SiteSettings', [
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
// Normalize empty strings to null so URL/email validation doesn't fail on blank fields
|
||||
foreach (['logo_url', 'favicon_url', 'terms_url', 'privacy_url', 'support_email', 'site_tagline', 'footer_text'] as $field) {
|
||||
if ($request->input($field) === '') {
|
||||
$request->merge([$field => null]);
|
||||
}
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'site_name' => 'required|string|max:100',
|
||||
'site_tagline' => 'nullable|string|max:200',
|
||||
'primary_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'logo_url' => 'nullable|url|max:500',
|
||||
'favicon_url' => 'nullable|url|max:500',
|
||||
'maintenance_mode' => 'boolean',
|
||||
'registration_open' => 'boolean',
|
||||
'min_deposit_usd' => 'required|numeric|min:0',
|
||||
'max_deposit_usd' => 'required|numeric|min:0',
|
||||
'min_withdrawal_usd' => 'required|numeric|min:0',
|
||||
'max_withdrawal_usd' => 'required|numeric|min:0',
|
||||
'max_bet_usd' => 'required|numeric|min:0',
|
||||
'house_edge_percent' => 'required|numeric|min:0|max:100',
|
||||
'footer_text' => 'nullable|string|max:1000',
|
||||
'support_email' => 'nullable|email|max:200',
|
||||
'terms_url' => 'nullable|string|max:500',
|
||||
'privacy_url' => 'nullable|string|max:500',
|
||||
'currency_symbol' => 'required|string|max:10',
|
||||
]);
|
||||
|
||||
AppSetting::put(self::KEY, $data);
|
||||
|
||||
return back()->with('success', 'Site settings saved.');
|
||||
}
|
||||
}
|
||||
127
app/Http/Controllers/Admin/SupportAdminController.php
Normal file
127
app/Http/Controllers/Admin/SupportAdminController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SupportAdminController extends Controller
|
||||
{
|
||||
private function assertAdmin(): void
|
||||
{
|
||||
if (!Auth::check() || strtolower((string) Auth::user()->role) !== 'admin') {
|
||||
abort(403, 'Nur für Admins');
|
||||
}
|
||||
}
|
||||
|
||||
private function ollamaStatus(): array
|
||||
{
|
||||
$host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/');
|
||||
$model = env('OLLAMA_MODEL', 'llama3');
|
||||
try {
|
||||
$res = Http::timeout(2)->get($host . '/api/tags');
|
||||
return ['healthy' => $res->ok(), 'host' => $host, 'model' => $model, 'error' => $res->ok() ? null : 'Keine Verbindung'];
|
||||
} catch (\Throwable $e) {
|
||||
return ['healthy' => false, 'host' => $host, 'model' => $model, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function getThreads(): array
|
||||
{
|
||||
$index = cache()->get('support_threads_index', []);
|
||||
$threads = [];
|
||||
foreach (array_reverse($index) as $row) {
|
||||
$full = cache()->get('support_threads:' . $row['id']);
|
||||
if (is_array($full)) {
|
||||
$threads[] = $full;
|
||||
} else {
|
||||
$threads[] = $row;
|
||||
}
|
||||
}
|
||||
return $threads;
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->assertAdmin();
|
||||
|
||||
$enabled = (bool) (cache()->get('support_chat_enabled') ?? config('app.support_chat_enabled', true));
|
||||
|
||||
return Inertia::render('Admin/Support', [
|
||||
'enabled' => $enabled,
|
||||
'threads' => $this->getThreads(),
|
||||
'ollama' => $this->ollamaStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(Request $request)
|
||||
{
|
||||
$this->assertAdmin();
|
||||
$data = $request->validate(['enabled' => 'required|boolean']);
|
||||
cache()->put('support_chat_enabled', (bool) $data['enabled'], now()->addYear());
|
||||
return back()->with('success', 'Support-Chat Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
public function reply(Request $request, string $thread)
|
||||
{
|
||||
$this->assertAdmin();
|
||||
$data = $request->validate(['text' => 'required|string|min:1|max:1000']);
|
||||
|
||||
$record = cache()->get('support_threads:' . $thread);
|
||||
if (!is_array($record)) {
|
||||
return back()->withErrors(['text' => 'Thread nicht gefunden.']);
|
||||
}
|
||||
|
||||
$record['messages'][] = [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'sender' => 'agent',
|
||||
'body' => $data['text'],
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
$record['status'] = 'agent';
|
||||
$record['updated_at'] = now()->toIso8601String();
|
||||
|
||||
cache()->put('support_threads:' . $thread, $record, now()->addDay());
|
||||
|
||||
// Update index entry
|
||||
$index = cache()->get('support_threads_index', []);
|
||||
foreach ($index as &$row) {
|
||||
if (($row['id'] ?? null) === $thread) {
|
||||
$row['status'] = 'agent';
|
||||
$row['updated_at'] = $record['updated_at'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
cache()->put('support_threads_index', $index, now()->addDay());
|
||||
|
||||
return back()->with('success', 'Nachricht gesendet.');
|
||||
}
|
||||
|
||||
public function close(Request $request, string $thread)
|
||||
{
|
||||
$this->assertAdmin();
|
||||
|
||||
$record = cache()->get('support_threads:' . $thread);
|
||||
if (is_array($record)) {
|
||||
$record['status'] = 'closed';
|
||||
$record['updated_at'] = now()->toIso8601String();
|
||||
cache()->put('support_threads:' . $thread, $record, now()->addDay());
|
||||
}
|
||||
|
||||
$index = cache()->get('support_threads_index', []);
|
||||
foreach ($index as &$row) {
|
||||
if (($row['id'] ?? null) === $thread) {
|
||||
$row['status'] = 'closed';
|
||||
break;
|
||||
}
|
||||
}
|
||||
cache()->put('support_threads_index', $index, now()->addDay());
|
||||
|
||||
return back()->with('success', 'Chat geschlossen.');
|
||||
}
|
||||
}
|
||||
73
app/Http/Controllers/Admin/WalletsAdminController.php
Normal file
73
app/Http/Controllers/Admin/WalletsAdminController.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WalletsAdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /admin/wallets/settings — Wallet/Vault policies and limits.
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user && ($user->role === 'Admin' || $user->role === 'Owner'), 403);
|
||||
|
||||
$defaults = [
|
||||
'pin_max_attempts' => 5,
|
||||
'pin_lock_minutes' => 15,
|
||||
'min_tx_btx' => 0.0001,
|
||||
'max_tx_btx' => 100000,
|
||||
'daily_max_btx' => 100000,
|
||||
'actions_per_minute' => 20,
|
||||
'reason_required' => true,
|
||||
];
|
||||
$settings = AppSetting::get('wallet.settings', $defaults) ?: $defaults;
|
||||
// Ensure defaults filled
|
||||
$settings = array_replace($defaults, is_array($settings) ? $settings : []);
|
||||
|
||||
return Inertia::render('Admin/WalletsSettings', [
|
||||
'settings' => $settings,
|
||||
'defaults' => $defaults,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/wallets/settings — Save policies and limits.
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user && ($user->role === 'Admin' || $user->role === 'Owner'), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'pin_max_attempts' => ['required','integer','min:1','max:20'],
|
||||
'pin_lock_minutes' => ['required','integer','min:1','max:1440'],
|
||||
'min_tx_btx' => ['required','numeric','min:0'],
|
||||
'max_tx_btx' => ['required','numeric','gt:min_tx_btx'],
|
||||
'daily_max_btx' => ['required','numeric','gte:max_tx_btx'],
|
||||
'actions_per_minute' => ['required','integer','min:1','max:600'],
|
||||
'reason_required' => ['required','boolean'],
|
||||
]);
|
||||
|
||||
// Normalize numeric precision (BTX uses 4 decimals commonly)
|
||||
$payload = [
|
||||
'pin_max_attempts' => (int) $data['pin_max_attempts'],
|
||||
'pin_lock_minutes' => (int) $data['pin_lock_minutes'],
|
||||
'min_tx_btx' => round((float) $data['min_tx_btx'], 4),
|
||||
'max_tx_btx' => round((float) $data['max_tx_btx'], 4),
|
||||
'daily_max_btx' => round((float) $data['daily_max_btx'], 4),
|
||||
'actions_per_minute' => (int) $data['actions_per_minute'],
|
||||
'reason_required' => (bool) $data['reason_required'],
|
||||
];
|
||||
|
||||
AppSetting::put('wallet.settings', $payload);
|
||||
|
||||
return back()->with('success', 'Wallet settings saved.');
|
||||
}
|
||||
}
|
||||
604
app/Http/Controllers/AdminController.php
Normal file
604
app/Http/Controllers/AdminController.php
Normal file
@@ -0,0 +1,604 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ChatMessageReport;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\ProfileLike;
|
||||
use App\Models\ProfileReport;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletTransfer;
|
||||
use App\Models\GameBet;
|
||||
use App\Models\UserRestriction;
|
||||
use App\Models\KycDocument;
|
||||
use App\Models\CryptoPayment;
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
private function ensureAdmin()
|
||||
{
|
||||
if (strtolower((string) Auth::user()->role) !== 'admin') {
|
||||
abort(403, 'Nur für Admins');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect old /admin to the new dashboard
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
return redirect()->route('admin.casino');
|
||||
}
|
||||
|
||||
public function casinoDashboard(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'total_wagered' => GameBet::sum('wager_amount'),
|
||||
'total_payout' => GameBet::sum('payout_amount'),
|
||||
'active_bans' => User::where('is_banned', true)->count(),
|
||||
'new_users_24h' => User::where('created_at', '>=', now()->subDay())->count(),
|
||||
];
|
||||
|
||||
$stats['house_edge'] = $stats['total_wagered'] - $stats['total_payout'];
|
||||
|
||||
// Get chart data for the last 7 days
|
||||
$chartData = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = Carbon::today()->subDays($i);
|
||||
$nextDate = Carbon::today()->subDays($i - 1);
|
||||
|
||||
$wagered = GameBet::whereBetween('created_at', [$date, $nextDate])->sum('wager_amount');
|
||||
$payout = GameBet::whereBetween('created_at', [$date, $nextDate])->sum('payout_amount');
|
||||
$newUsers = User::whereBetween('created_at', [$date, $nextDate])->count();
|
||||
|
||||
$chartData[] = [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'label' => $date->format('D, d M'),
|
||||
'wagered' => (float)$wagered,
|
||||
'payout' => (float)$payout,
|
||||
'profit' => (float)($wagered - $payout),
|
||||
'new_users' => $newUsers,
|
||||
];
|
||||
}
|
||||
|
||||
$recent_bets = GameBet::with('user:id,username')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recent_users = User::orderByDesc('id')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return Inertia::render('Admin/CasinoDashboard', [
|
||||
'stats' => $stats,
|
||||
'chartData' => $chartData,
|
||||
'recentBets' => $recent_bets,
|
||||
'recentUsers' => $recent_users,
|
||||
]);
|
||||
}
|
||||
|
||||
public function usersIndex(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$query = User::orderByDesc('id');
|
||||
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('id', 'like', "%$search%")
|
||||
->orWhere('username', 'like', "%$search%")
|
||||
->orWhere('email', 'like', "%$search%")
|
||||
->orWhere('first_name', 'like', "%$search%")
|
||||
->orWhere('last_name', 'like', "%$search%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('role') && $request->input('role') !== '') {
|
||||
$query->where('role', $request->input('role'));
|
||||
}
|
||||
|
||||
$users = $query->paginate(20)->withQueryString();
|
||||
$roles = ['Admin', 'Moderator', 'User'];
|
||||
|
||||
return Inertia::render('Admin/Users', [
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['search', 'role'])
|
||||
]);
|
||||
}
|
||||
|
||||
public function userShow(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
$user = User::with('wallets')->findOrFail($id);
|
||||
|
||||
$restrictions = UserRestriction::where('user_id', $user->id)->orderByDesc('id')->get();
|
||||
|
||||
$vaultTransfers = WalletTransfer::where('user_id', $user->id)
|
||||
->whereIn('type', ['vault_deposit', 'vault_withdraw'])
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$deposits = CryptoPayment::where('user_id', $user->id)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$kycDocuments = class_exists(KycDocument::class)
|
||||
? KycDocument::where('user_id', $user->id)->get()
|
||||
: [];
|
||||
|
||||
return Inertia::render('Admin/UserShow', [
|
||||
'user' => $user,
|
||||
'restrictions' => $restrictions,
|
||||
'wallets' => $user->wallets,
|
||||
'vaultTransfers' => $vaultTransfers,
|
||||
'deposits' => $deposits,
|
||||
'kycDocuments' => $kycDocuments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUser(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'first_name' => 'nullable|string|max:255',
|
||||
'last_name' => 'nullable|string|max:255',
|
||||
'birthdate' => 'nullable|date',
|
||||
'gender' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'country' => 'nullable|string|max:10',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'postal_code' => 'nullable|string|max:50',
|
||||
'currency' => 'nullable|string|max:10',
|
||||
'vip_level' => 'nullable|integer|min:0|max:100',
|
||||
'balance' => 'nullable|numeric',
|
||||
'vault_balance' => 'nullable|numeric',
|
||||
'is_banned' => 'boolean',
|
||||
'is_chat_banned' => 'boolean',
|
||||
'ban_reason' => 'nullable|string|max:255',
|
||||
'role' => 'nullable|string|max:50'
|
||||
]);
|
||||
|
||||
DB::transaction(function() use ($id, $validated, $request) {
|
||||
$user = User::where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$user->fill($validated);
|
||||
|
||||
// Update restrictions for ban
|
||||
if ($request->has('is_banned')) {
|
||||
$user->is_banned = $request->input('is_banned');
|
||||
if ($user->is_banned) {
|
||||
UserRestriction::updateOrCreate(
|
||||
['user_id' => $user->id, 'type' => 'account_ban'],
|
||||
[
|
||||
'active' => true,
|
||||
'reason' => $request->input('ban_reason'),
|
||||
'expires_at' => $request->input('ban_ends_at')
|
||||
]
|
||||
);
|
||||
} else {
|
||||
UserRestriction::where('user_id', $user->id)->where('type', 'account_ban')->update(['active' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update restrictions for chat ban (via UserRestriction only, no column on users table)
|
||||
if ($request->has('is_chat_banned')) {
|
||||
if ($request->boolean('is_chat_banned')) {
|
||||
UserRestriction::updateOrCreate(
|
||||
['user_id' => $user->id, 'type' => 'chat_ban'],
|
||||
[
|
||||
'active' => true,
|
||||
'reason' => 'Admin intervention',
|
||||
'ends_at' => $request->input('chat_ban_ends_at'),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
UserRestriction::where('user_id', $user->id)->where('type', 'chat_ban')->update(['active' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
$user->save();
|
||||
});
|
||||
|
||||
return back()->with('success', 'User updated successfully.');
|
||||
}
|
||||
|
||||
public function userHistory(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// Fetch wallet transfers as history
|
||||
$transfers = WalletTransfer::where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => $transfers]);
|
||||
}
|
||||
|
||||
public function chatIndex(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$aiEnabled = AppSetting::get('chat.ai_enabled', false);
|
||||
|
||||
return Inertia::render('Admin/Chat', [
|
||||
'aiEnabled' => $aiEnabled
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleAi(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
$enabled = $request->input('enabled', false);
|
||||
AppSetting::put('chat.ai_enabled', $enabled);
|
||||
|
||||
return back()->with('success', 'AI Chat ' . ($enabled ? 'enabled' : 'disabled'));
|
||||
}
|
||||
|
||||
public function deleteChatMessage($id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
// implement chat deletion - typically this would proxy to the backend API
|
||||
// For now, we'll just mock it as success if we don't have direct DB access to it here
|
||||
// (ChatMessage model exists locally, but messages might be on the upstream)
|
||||
return back()->with('success', 'Message deleted (Mock)');
|
||||
}
|
||||
|
||||
public function chatReportShow(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$report = ChatMessageReport::with([
|
||||
'reporter:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||
])->findOrFail($id);
|
||||
|
||||
$restrictionWith = fn ($q) => $q->withTrashed(false)->orderByDesc('created_at');
|
||||
|
||||
$senderUser = null;
|
||||
if ($report->sender_id) {
|
||||
$senderUser = User::with(['restrictions' => $restrictionWith])
|
||||
->find($report->sender_id);
|
||||
}
|
||||
|
||||
$reporterUser = User::with(['restrictions' => $restrictionWith])
|
||||
->find($report->reporter_id);
|
||||
|
||||
return Inertia::render('Admin/ChatReportShow', [
|
||||
'report' => $report,
|
||||
'senderUser' => $senderUser,
|
||||
'reporterUser' => $reporterUser,
|
||||
'flash' => session('success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function punishFromChatReport(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$report = ChatMessageReport::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:chat_ban,account_ban',
|
||||
'reason' => 'required|string|max:300',
|
||||
'hours' => 'nullable|integer|min:1', // null = permanent
|
||||
]);
|
||||
|
||||
$targetId = $report->sender_id;
|
||||
if (!$targetId) {
|
||||
return back()->withErrors(['error' => 'Kein Nutzer mit dieser Nachricht verknüpft.']);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($targetId);
|
||||
$admin = Auth::user();
|
||||
$endsAt = $validated['hours'] ? now()->addHours($validated['hours']) : null;
|
||||
|
||||
UserRestriction::updateOrCreate(
|
||||
['user_id' => $user->id, 'type' => $validated['type']],
|
||||
[
|
||||
'active' => true,
|
||||
'reason' => $validated['reason'],
|
||||
'notes' => "Via Chat-Report #$report->id",
|
||||
'imposed_by' => $admin->id,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => $endsAt,
|
||||
'source' => 'admin_panel',
|
||||
'metadata' => ['report_id' => $report->id],
|
||||
]
|
||||
);
|
||||
|
||||
if ($validated['type'] === 'account_ban') {
|
||||
$user->update([
|
||||
'is_banned' => true,
|
||||
'ban_reason' => $validated['reason'],
|
||||
]);
|
||||
}
|
||||
|
||||
$report->update(['status' => 'reviewed']);
|
||||
|
||||
return back()->with('success', 'Strafe wurde erfolgreich verhängt.');
|
||||
}
|
||||
|
||||
public function chatReports(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$query = ChatMessageReport::with('reporter:id,username,email,avatar,avatar_url')
|
||||
->orderByDesc('id');
|
||||
|
||||
$status = $request->input('status', 'pending');
|
||||
if ($status && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
if ($search !== '') {
|
||||
if (is_numeric($search)) {
|
||||
$query->where('id', $search);
|
||||
} else {
|
||||
$q = '%' . $search . '%';
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('sender_username', 'like', $q)
|
||||
->orWhereHas('reporter', fn($r) => $r->where('username', 'like', $q));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'pending' => ChatMessageReport::where('status', 'pending')->count(),
|
||||
'reviewed' => ChatMessageReport::where('status', 'reviewed')->count(),
|
||||
'dismissed' => ChatMessageReport::where('status', 'dismissed')->count(),
|
||||
];
|
||||
$stats['total'] = $stats['pending'] + $stats['reviewed'] + $stats['dismissed'];
|
||||
|
||||
$reports = $query->paginate(25)->withQueryString();
|
||||
|
||||
return Inertia::render('Admin/ChatReports', [
|
||||
'reports' => $reports,
|
||||
'filters' => $request->only(['status', 'search']),
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateChatReport(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:pending,reviewed,dismissed',
|
||||
'admin_note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$report = ChatMessageReport::findOrFail($id);
|
||||
$report->update($validated);
|
||||
|
||||
return back()->with('success', 'Report updated.');
|
||||
}
|
||||
|
||||
public function liftRestriction(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$restriction = UserRestriction::findOrFail($id);
|
||||
$restriction->update(['active' => false]);
|
||||
|
||||
if ($restriction->type === 'account_ban') {
|
||||
User::where('id', $restriction->user_id)->update(['is_banned' => false]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Sperre aufgehoben.');
|
||||
}
|
||||
|
||||
public function extendRestriction(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'hours' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$restriction = UserRestriction::findOrFail($id);
|
||||
$base = $restriction->ends_at && $restriction->ends_at->isFuture()
|
||||
? $restriction->ends_at
|
||||
: now();
|
||||
|
||||
$restriction->update(['ends_at' => $base->addHours($validated['hours'])]);
|
||||
|
||||
return back()->with('success', 'Sperre verlängert.');
|
||||
}
|
||||
|
||||
public function profileReports(Request $request)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$query = ProfileReport::with([
|
||||
'reporter:id,username,email,avatar,avatar_url',
|
||||
'profile:id,username,email,avatar,avatar_url,role,vip_level,bio',
|
||||
])->orderByDesc('id');
|
||||
|
||||
$status = $request->input('status', 'pending');
|
||||
if ($status && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
if ($search !== '') {
|
||||
if (is_numeric($search)) {
|
||||
$query->where('id', $search);
|
||||
} else {
|
||||
$q = '%' . $search . '%';
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->whereHas('reporter', fn($r) => $r->where('username', 'like', $q))
|
||||
->orWhereHas('profile', fn($r) => $r->where('username', 'like', $q));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'pending' => ProfileReport::where('status', 'pending')->count(),
|
||||
'reviewed' => ProfileReport::where('status', 'reviewed')->count(),
|
||||
'dismissed' => ProfileReport::where('status', 'dismissed')->count(),
|
||||
];
|
||||
$stats['total'] = $stats['pending'] + $stats['reviewed'] + $stats['dismissed'];
|
||||
|
||||
$reports = $query->paginate(25)->withQueryString();
|
||||
|
||||
return Inertia::render('Admin/ProfileReports', [
|
||||
'reports' => $reports,
|
||||
'filters' => $request->only(['status', 'search']),
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function profileReportShow(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$report = ProfileReport::with([
|
||||
'reporter:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||
'profile:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||
])->findOrFail($id);
|
||||
|
||||
// Attach restrictions to both users
|
||||
$reporterUser = null;
|
||||
$profileUser = null;
|
||||
|
||||
if ($report->reporter_id) {
|
||||
$reporterUser = User::with(['restrictions' => function ($q) {
|
||||
$q->orderByDesc('id');
|
||||
}])->find($report->reporter_id);
|
||||
}
|
||||
if ($report->profile_id) {
|
||||
$profileUser = User::with(['restrictions' => function ($q) {
|
||||
$q->orderByDesc('id');
|
||||
}])->find($report->profile_id);
|
||||
}
|
||||
|
||||
// Load current live profile data from DB for comparison
|
||||
$currentProfile = null;
|
||||
if ($report->profile_id) {
|
||||
$u = User::find($report->profile_id);
|
||||
if ($u) {
|
||||
$likesCount = ProfileLike::where('profile_id', $u->id)->count();
|
||||
$comments = ProfileComment::with(['user:id,username,avatar'])
|
||||
->where('profile_id', $u->id)
|
||||
->latest()
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'content' => $c->content,
|
||||
'created_at' => $c->created_at,
|
||||
'user' => [
|
||||
'id' => $c->user->id,
|
||||
'username' => $c->user->username,
|
||||
'avatar' => $c->user->avatar,
|
||||
],
|
||||
]);
|
||||
|
||||
$currentProfile = [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'avatar' => $u->avatar ?? $u->avatar_url,
|
||||
'banner' => $u->banner,
|
||||
'bio' => $u->bio,
|
||||
'role' => $u->role ?? 'User',
|
||||
'vip_level' => (int)($u->vip_level ?? 0),
|
||||
'clan_tag' => $u->clan_tag,
|
||||
'stats' => [
|
||||
'wagered' => (float)($u->stats?->total_wagered ?? 0),
|
||||
'wins' => (int)($u->stats?->total_wins ?? 0),
|
||||
'biggest_win' => (float)($u->stats?->biggest_win ?? 0),
|
||||
'likes_count' => $likesCount,
|
||||
'join_date' => optional($u->created_at)->toDateString(),
|
||||
],
|
||||
'comments' => $comments,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$screenshotUrl = $report->screenshot_path
|
||||
? asset('storage/' . $report->screenshot_path)
|
||||
: null;
|
||||
|
||||
return Inertia::render('Admin/ProfileReportShow', [
|
||||
'report' => $report,
|
||||
'reporterUser' => $reporterUser,
|
||||
'profileUser' => $profileUser,
|
||||
'screenshotUrl' => $screenshotUrl,
|
||||
'currentProfile' => $currentProfile,
|
||||
'flash' => session('success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProfileReport(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:pending,reviewed,dismissed',
|
||||
'admin_note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$report = ProfileReport::findOrFail($id);
|
||||
$report->update($validated);
|
||||
|
||||
return back()->with('success', 'Report updated.');
|
||||
}
|
||||
|
||||
public function punishFromProfileReport(Request $request, $id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$data = $request->validate([
|
||||
'type' => 'required|in:chat_ban,account_ban',
|
||||
'reason' => 'required|string|max:500',
|
||||
'hours' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$report = ProfileReport::findOrFail($id);
|
||||
$target = User::findOrFail($report->profile_id);
|
||||
|
||||
$startsAt = now();
|
||||
$endsAt = $data['hours'] ? now()->addHours($data['hours']) : null;
|
||||
|
||||
UserRestriction::create([
|
||||
'user_id' => $target->id,
|
||||
'type' => $data['type'],
|
||||
'reason' => $data['reason'],
|
||||
'active' => true,
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
]);
|
||||
|
||||
if ($data['type'] === 'account_ban') {
|
||||
$target->update(['is_banned' => true]);
|
||||
}
|
||||
|
||||
$report->update(['status' => 'reviewed']);
|
||||
|
||||
return back()->with('success', 'Strafe erfolgreich verhängt!');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Auth/AvailabilityController.php
Normal file
69
app/Http/Controllers/Auth/AvailabilityController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AvailabilityController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability for username or email during registration via upstream API.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$field = $request->query('field');
|
||||
$value = (string) $request->query('value', '');
|
||||
|
||||
if (!in_array($field, ['username', 'email'], true)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'Unsupported field',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Basic format checks to reduce unnecessary upstream calls
|
||||
if ($field === 'username' && mb_strlen($value) < 3) {
|
||||
return response()->json(['ok' => true, 'available' => false, 'reason' => 'too_short']);
|
||||
}
|
||||
if ($field === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
return response()->json(['ok' => true, 'available' => false, 'reason' => 'invalid_format']);
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $this->client->get($request, '/api/auth/availability', [
|
||||
'field' => $field,
|
||||
'value' => $value,
|
||||
], retry: true);
|
||||
|
||||
if ($res->successful()) {
|
||||
$j = $res->json() ?: [];
|
||||
// Normalize to { ok: true, available: bool, reason? }
|
||||
$available = $j['available'] ?? $j['is_available'] ?? null;
|
||||
if ($available !== null) {
|
||||
$out = ['ok' => true, 'available' => (bool) $available];
|
||||
if (isset($j['reason'])) {
|
||||
$out['reason'] = $j['reason'];
|
||||
}
|
||||
return response()->json($out, 200);
|
||||
}
|
||||
// Fallback: pass-through
|
||||
return response()->json($j, 200);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
|
||||
class EmailVerificationCodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle email verification via short code.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'code' => ['required','string','regex:/^\d{6}$/'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
return redirect()->route('dashboard')->with('status', 'Email already verified.');
|
||||
}
|
||||
|
||||
$cacheKey = 'email_verify_code:'.$user->getKey();
|
||||
$expected = Cache::get($cacheKey);
|
||||
|
||||
// Normalize submitted code to digits-only string
|
||||
$submitted = (string) $request->input('code', '');
|
||||
$submitted = preg_replace('/\D+/', '', $submitted ?? '');
|
||||
|
||||
if (!$expected || $expected !== $submitted) {
|
||||
return back()->withErrors(['code' => 'Invalid or expired verification code.']);
|
||||
}
|
||||
|
||||
// Mark as verified and clear the code
|
||||
if ($user->markEmailAsVerified()) {
|
||||
event(new Verified($user));
|
||||
}
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
return redirect()->route('dashboard')->with('status', 'Email verified successfully.');
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/BetiXWebhookController.php
Normal file
171
app/Http/Controllers/BetiXWebhookController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GameBet;
|
||||
use App\Models\OperatorSession;
|
||||
use App\Models\User;
|
||||
use App\Services\BetiXClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BetiXWebhookController extends Controller
|
||||
{
|
||||
public function sessionEnded(Request $request, BetiXClient $client): \Illuminate\Http\Response
|
||||
{
|
||||
$rawBody = $request->getContent();
|
||||
$sig = $request->header('X-Signature', '');
|
||||
$secret = config('services.betix.webhook_secret', '');
|
||||
|
||||
// If a webhook secret is configured, verify the signature
|
||||
if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) {
|
||||
Log::warning('[BetiX] Webhook signature mismatch', ['ip' => $request->ip()]);
|
||||
return response('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!$payload || !isset($payload['session_token'])) {
|
||||
return response('Bad Request', 400);
|
||||
}
|
||||
|
||||
$token = $payload['session_token'];
|
||||
$playerId = $payload['player_id'] ?? null;
|
||||
$endBalance = isset($payload['end_balance']) ? (float) $payload['end_balance'] : null;
|
||||
$startBalance = isset($payload['start_balance']) ? (float) $payload['start_balance'] : null;
|
||||
$game = $payload['game'] ?? null;
|
||||
$rounds = $payload['rounds'] ?? 0;
|
||||
|
||||
$session = OperatorSession::where('session_token', $token)->first();
|
||||
|
||||
if (!$session) {
|
||||
// Unknown session — could be a replay or a stale token, acknowledge anyway
|
||||
Log::info('[BetiX] Webhook for unknown session', ['token' => $token]);
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
if ($session->status !== 'active') {
|
||||
// Already processed (idempotency)
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($session, $playerId, $endBalance, $startBalance, $rounds, $game) {
|
||||
// Mark session as expired
|
||||
$session->update([
|
||||
'status' => 'expired',
|
||||
'current_balance' => $endBalance ?? $session->current_balance,
|
||||
]);
|
||||
|
||||
if ($endBalance === null || $playerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply balance delta to user (safe even if other transactions happened mid-session)
|
||||
$user = User::lockForUpdate()->find((int) $playerId);
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStart = $startBalance ?? (float) $session->start_balance;
|
||||
$delta = $endBalance - $sessionStart; // positive = player won, negative = player lost
|
||||
|
||||
$newBalance = max(0, (float) $user->balance + $delta);
|
||||
$user->balance = $newBalance;
|
||||
$user->save();
|
||||
|
||||
Log::info(sprintf(
|
||||
'[BetiX] Session ended | player=%s | game=%s | rounds=%d | delta=%+.4f BTX | new_balance=%.4f',
|
||||
$playerId, $game ?? '?', $rounds, $delta, $newBalance
|
||||
));
|
||||
});
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/betix/round
|
||||
*
|
||||
* Called by the BetiX game server after each completed round.
|
||||
* Updates the user's balance incrementally and logs to game_bets.
|
||||
*
|
||||
* Payload: { session_token, player_id, new_balance, currency, server_seed_hash, round }
|
||||
*/
|
||||
public function roundUpdate(Request $request, BetiXClient $client): \Illuminate\Http\Response
|
||||
{
|
||||
$rawBody = $request->getContent();
|
||||
$sig = $request->header('X-Signature', '');
|
||||
$secret = config('services.betix.webhook_secret', '');
|
||||
|
||||
if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) {
|
||||
Log::warning('[BetiX] Round webhook signature mismatch', ['ip' => $request->ip()]);
|
||||
return response('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!$payload || !isset($payload['session_token'], $payload['player_id'], $payload['new_balance'])) {
|
||||
return response('Bad Request', 400);
|
||||
}
|
||||
|
||||
$token = $payload['session_token'];
|
||||
$playerId = (int) $payload['player_id'];
|
||||
$newBalance = (float) $payload['new_balance'];
|
||||
$seedHash = $payload['server_seed_hash'] ?? null;
|
||||
$roundNumber = $payload['round'] ?? null;
|
||||
$currency = strtoupper($payload['currency'] ?? 'EUR');
|
||||
|
||||
$session = OperatorSession::where('session_token', $token)->first();
|
||||
|
||||
if (!$session || $session->status !== 'active') {
|
||||
// Unknown or already-ended session — acknowledge without processing
|
||||
return response('OK', 200);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($session, $playerId, $newBalance, $seedHash, $roundNumber, $currency) {
|
||||
$oldSessionBalance = (float) $session->current_balance;
|
||||
$delta = $newBalance - $oldSessionBalance; // positive = win, negative = loss
|
||||
|
||||
// Update session's running balance + optionally rotate the seed hash
|
||||
$sessionUpdate = ['current_balance' => $newBalance];
|
||||
if ($seedHash) {
|
||||
$sessionUpdate['server_seed_hash'] = $seedHash;
|
||||
}
|
||||
$session->update($sessionUpdate);
|
||||
|
||||
// Apply balance change to user (row-locked for safety)
|
||||
$user = User::lockForUpdate()->find($playerId);
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userBalanceBefore = (float) $user->balance;
|
||||
$userBalanceAfter = max(0.0, $userBalanceBefore + $delta);
|
||||
$user->balance = $userBalanceAfter;
|
||||
$user->save();
|
||||
|
||||
// Log the round to game_bets (wager = loss side, payout = win side)
|
||||
GameBet::create([
|
||||
'user_id' => $playerId,
|
||||
'game_name' => $session->game_slug,
|
||||
'wager_amount' => $delta < 0 ? abs($delta) : 0,
|
||||
'payout_amount' => $delta > 0 ? $delta : 0,
|
||||
'payout_multiplier'=> 0,
|
||||
'currency' => $currency,
|
||||
'session_token' => $session->session_token,
|
||||
'round_number' => $roundNumber,
|
||||
'server_seed_hash' => $seedHash,
|
||||
]);
|
||||
|
||||
Log::info(sprintf(
|
||||
'[BetiX] Round update | player=%d | game=%s | round=%s | delta=%+.4f | balance=%.4f→%.4f',
|
||||
$playerId,
|
||||
$session->game_slug,
|
||||
$roundNumber ?? '?',
|
||||
$delta,
|
||||
$userBalanceBefore,
|
||||
$userBalanceAfter
|
||||
));
|
||||
});
|
||||
|
||||
return response('OK', 200);
|
||||
}
|
||||
}
|
||||
156
app/Http/Controllers/BonusApiController.php
Normal file
156
app/Http/Controllers/BonusApiController.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BonusApiController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
// Inline token check middleware (no alias registration needed)
|
||||
$this->middleware(function ($request, $next) {
|
||||
$provided = $this->extractToken($request);
|
||||
$expected = config('services.bonus_api.token');
|
||||
|
||||
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): ?string
|
||||
{
|
||||
$auth = $request->header('Authorization');
|
||||
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||
return substr($auth, 7);
|
||||
}
|
||||
return $request->query('api_token'); // fallback: allow ?api_token=
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$query = [];
|
||||
if ($status = $request->query('status')) {
|
||||
$query['status'] = $status;
|
||||
}
|
||||
if ($request->boolean('active_only')) {
|
||||
$query['active_only'] = 1;
|
||||
}
|
||||
$query['per_page'] = min(200, max(1, (int) $request->query('per_page', 50)));
|
||||
|
||||
$res = $this->client->get($request, '/bonuses', $query, retry: true);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->get($request, "/bonuses/{$id}", [], retry: true);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validateData($request);
|
||||
try {
|
||||
$res = $this->client->post($request, '/bonuses', $data);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: [], 201);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$data = $this->validateData($request, partial: true);
|
||||
try {
|
||||
$res = $this->client->patch($request, "/bonuses/{$id}", $data);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->delete($request, "/bonuses/{$id}");
|
||||
if ($res->successful()) {
|
||||
$body = $res->json();
|
||||
return response()->json($body ?: ['message' => 'Deleted']);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
$rules = [
|
||||
'title' => [$required, 'string', 'max:255'],
|
||||
'type' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'amount_value' => ['sometimes', 'nullable', 'numeric'],
|
||||
'amount_unit' => ['sometimes', 'nullable', Rule::in(['USD','EUR','BTC','ETH','PERCENT','SPINS'])],
|
||||
'min_deposit' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||
'max_amount' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||
'currency' => ['sometimes', 'nullable', 'string', 'max:16'],
|
||||
'code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'status' => ['sometimes', 'required', Rule::in(['draft','active','paused','expired'])],
|
||||
'starts_at' => ['sometimes', 'nullable', 'date'],
|
||||
'expires_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'rules' => ['sometimes', 'nullable', 'array'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
// Normalize empty strings to null
|
||||
foreach (['type','amount_unit','currency','code','description'] as $k) {
|
||||
if (array_key_exists($k, $validated) && $validated[$k] === '') {
|
||||
$validated[$k] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/BonusesController.php
Normal file
70
app/Http/Controllers/BonusesController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BonusesController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight JSON for the in-app Bonuses page.
|
||||
* Authenticated web users only (route middleware handles auth+throttle).
|
||||
*/
|
||||
public function appIndex(Request $request)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->get($request, '/bonuses/app', [
|
||||
'limit' => min(100, (int) $request->query('limit', 50)),
|
||||
], retry: true);
|
||||
|
||||
if ($res->successful()) {
|
||||
$body = $res->json() ?: [];
|
||||
$available = $body['available'] ?? $body['bonuses'] ?? [];
|
||||
$active = $body['active'] ?? [];
|
||||
$history = $body['history'] ?? [];
|
||||
|
||||
// Normalize available list to fields expected by UI (keep unknowns as-is)
|
||||
$availableDto = [];
|
||||
foreach ((array) $available as $b) {
|
||||
if (!is_array($b)) continue;
|
||||
$availableDto[] = [
|
||||
'id' => $b['id'] ?? null,
|
||||
'title' => $b['title'] ?? null,
|
||||
'type' => $b['type'] ?? null,
|
||||
'amount_value' => isset($b['amount_value']) ? (float) $b['amount_value'] : (isset($b['amount']) ? (float) $b['amount'] : null),
|
||||
'amount_unit' => $b['amount_unit'] ?? $b['unit'] ?? null,
|
||||
'min_deposit' => isset($b['min_deposit']) ? (float) $b['min_deposit'] : null,
|
||||
'max_amount' => isset($b['max_amount']) ? (float) $b['max_amount'] : null,
|
||||
'currency' => $b['currency'] ?? null,
|
||||
'code' => $b['code'] ?? null,
|
||||
'starts_at' => $b['starts_at'] ?? null,
|
||||
'expires_at' => $b['expires_at'] ?? null,
|
||||
'description' => $b['description'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'available' => $availableDto,
|
||||
'active' => $active,
|
||||
'history' => $history,
|
||||
'now' => $body['now'] ?? now()->toIso8601String(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
249
app/Http/Controllers/ChatController.php
Normal file
249
app/Http/Controllers/ChatController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ChatMessage;
|
||||
use App\Models\ChatMessageReaction;
|
||||
use App\Models\ChatMessageReport;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
private function formatMessage(ChatMessage $m, int $authId = 0): array
|
||||
{
|
||||
$user = $m->user;
|
||||
$reactionsAgg = [];
|
||||
|
||||
foreach ($m->reactions->groupBy('emoji') as $emoji => $group) {
|
||||
$reactionsAgg[] = [
|
||||
'emoji' => $emoji,
|
||||
'count' => $group->count(),
|
||||
'reactedByMe' => $group->contains('user_id', $authId),
|
||||
];
|
||||
}
|
||||
|
||||
$replyTo = null;
|
||||
if ($m->reply_to_id && $m->replyTo) {
|
||||
$replyTo = [
|
||||
'id' => $m->replyTo->id,
|
||||
'message' => $m->replyTo->message,
|
||||
'user' => [
|
||||
'id' => $m->replyTo->user?->id,
|
||||
'username' => $m->replyTo->user?->username,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'user_id' => $m->user_id,
|
||||
'message' => $m->is_deleted ? null : $m->message,
|
||||
'is_deleted' => (bool) $m->is_deleted,
|
||||
'deleted_by_user' => $m->is_deleted && $m->deletedByUser ? [
|
||||
'id' => $m->deletedByUser->id,
|
||||
'username' => $m->deletedByUser->username,
|
||||
'role' => $m->deletedByUser->role ?? 'user',
|
||||
] : null,
|
||||
'reply_to_id' => $m->reply_to_id,
|
||||
'reply_to' => $replyTo,
|
||||
'created_at' => $m->created_at?->toIso8601String(),
|
||||
'user' => [
|
||||
'id' => $user?->id,
|
||||
'username' => $user?->username,
|
||||
'avatar_url' => $user?->avatar_url ?? $user?->avatar ?? null,
|
||||
'role' => $user?->role ?? 'user',
|
||||
'vip_level' => (int) ($user?->vip_level ?? 0),
|
||||
'clan_tag' => $user?->clan_tag ?? null,
|
||||
],
|
||||
'reactions_agg' => $reactionsAgg,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent chat messages from local DB.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$limit = min(max((int) $request->query('limit', 50), 1), 200);
|
||||
$afterId = $request->query('after_id');
|
||||
$authId = (int) optional($request->user())->id;
|
||||
|
||||
$query = ChatMessage::with([
|
||||
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||
'reactions',
|
||||
'replyTo:id,message,user_id',
|
||||
'replyTo.user:id,username',
|
||||
'deletedByUser:id,username,role',
|
||||
])->orderBy('id', 'asc');
|
||||
|
||||
if ($afterId !== null) {
|
||||
$query->where('id', '>', (int) $afterId);
|
||||
} else {
|
||||
// Return last N messages
|
||||
$query = ChatMessage::with([
|
||||
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||
'reactions',
|
||||
'replyTo:id,message,user_id',
|
||||
'replyTo.user:id,username',
|
||||
])->orderBy('id', 'desc')->limit($limit);
|
||||
|
||||
$messages = $query->get()->reverse()->values();
|
||||
$formatted = $messages->map(fn ($m) => $this->formatMessage($m, $authId))->values()->all();
|
||||
$lastId = $messages->last()?->id;
|
||||
|
||||
return response()->json(['data' => $formatted, 'last_id' => $lastId]);
|
||||
}
|
||||
|
||||
$messages = $query->limit($limit)->get();
|
||||
$formatted = $messages->map(fn ($m) => $this->formatMessage($m, $authId))->values()->all();
|
||||
$lastId = $messages->last()?->id;
|
||||
|
||||
return response()->json(['data' => $formatted, 'last_id' => $lastId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new chat message in local DB.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'min:1', 'max:300'],
|
||||
'reply_to_id' => ['nullable', 'integer', 'exists:chat_messages,id'],
|
||||
]);
|
||||
|
||||
// Check local chat ban
|
||||
$chatBan = \App\Models\UserRestriction::where('user_id', $user->id)
|
||||
->where('type', 'chat_ban')
|
||||
->where('active', true)
|
||||
->where(fn($q) => $q->whereNull('ends_at')->orWhere('ends_at', '>', now()))
|
||||
->first();
|
||||
if ($chatBan) {
|
||||
return response()->json([
|
||||
'message' => 'Du bist vom Chat gebannt.',
|
||||
'type' => 'chat_ban',
|
||||
'ends_at' => $chatBan->ends_at?->toIso8601String(),
|
||||
], 403);
|
||||
}
|
||||
|
||||
$msg = ChatMessage::create([
|
||||
'user_id' => $user->id,
|
||||
'message' => $validated['message'],
|
||||
'reply_to_id' => $validated['reply_to_id'] ?? null,
|
||||
]);
|
||||
|
||||
$msg->load([
|
||||
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||
'reactions',
|
||||
'replyTo:id,message,user_id',
|
||||
'replyTo.user:id,username',
|
||||
'deletedByUser:id,username,role',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a chat message (admin/mod only).
|
||||
*/
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$role = strtolower((string) $user->role);
|
||||
if (!in_array($role, ['admin', 'moderator', 'mod'])) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$msg = ChatMessage::findOrFail($id);
|
||||
$msg->update(['is_deleted' => true, 'deleted_by' => $user->id]);
|
||||
|
||||
$msg->load([
|
||||
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||
'reactions',
|
||||
'replyTo:id,message,user_id',
|
||||
'replyTo.user:id,username',
|
||||
'deletedByUser:id,username,role',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a reaction on a message.
|
||||
*/
|
||||
public function react(Request $request, int $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$validated = $request->validate([
|
||||
'emoji' => ['required', 'string', 'max:8'],
|
||||
]);
|
||||
|
||||
$msg = ChatMessage::findOrFail($id);
|
||||
|
||||
$existing = ChatMessageReaction::where([
|
||||
'message_id' => $msg->id,
|
||||
'user_id' => $user->id,
|
||||
'emoji' => $validated['emoji'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
ChatMessageReaction::create([
|
||||
'message_id' => $msg->id,
|
||||
'user_id' => $user->id,
|
||||
'emoji' => $validated['emoji'],
|
||||
]);
|
||||
}
|
||||
|
||||
$msg->load([
|
||||
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||
'reactions',
|
||||
'replyTo:id,message,user_id',
|
||||
'replyTo.user:id,username',
|
||||
'deletedByUser:id,username,role',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a chat message.
|
||||
*/
|
||||
public function report(Request $request, int $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$validated = $request->validate([
|
||||
'message_text' => ['required', 'string', 'max:500'],
|
||||
'sender_id' => ['nullable', 'integer'],
|
||||
'sender_username' => ['nullable', 'string', 'max:255'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'context_messages' => ['nullable', 'array'],
|
||||
'context_messages.*.id' => ['nullable'],
|
||||
'context_messages.*.message' => ['nullable', 'string', 'max:500'],
|
||||
'context_messages.*.user' => ['nullable', 'array'],
|
||||
'context_messages.*.created_at' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
ChatMessageReport::create([
|
||||
'reporter_id' => $user->id,
|
||||
'message_id' => (string) $id,
|
||||
'message_text' => $validated['message_text'],
|
||||
'sender_id' => $validated['sender_id'] ?? null,
|
||||
'sender_username' => $validated['sender_username'] ?? null,
|
||||
'reason' => $validated['reason'] ?? null,
|
||||
'context_messages' => $validated['context_messages'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Reported.'], 201);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Concerns/ProxiesBackend.php
Normal file
34
app/Http/Controllers/Concerns/ProxiesBackend.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
trait ProxiesBackend
|
||||
{
|
||||
protected function mapClientError(Response $res)
|
||||
{
|
||||
$msg = data_get($res->json(), 'message') ?? 'Invalid request';
|
||||
return response()->json([
|
||||
'error' => 'client_error',
|
||||
'message' => $msg,
|
||||
], $res->status());
|
||||
}
|
||||
|
||||
protected function mapServiceUnavailable(Response $res)
|
||||
{
|
||||
$msg = data_get($res->json(), 'message') ?? 'Internal server error';
|
||||
return response()->json([
|
||||
'error' => 'service_unavailable',
|
||||
'message' => $msg,
|
||||
], 503);
|
||||
}
|
||||
|
||||
protected function mapBadGateway(string $msg = 'API server not reachable')
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'bad_gateway',
|
||||
'message' => $msg,
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
122
app/Http/Controllers/DepositController.php
Normal file
122
app/Http/Controllers/DepositController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CryptoPayment;
|
||||
use App\Services\DepositService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class DepositController extends Controller
|
||||
{
|
||||
public function __construct(private readonly DepositService $deposits)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wallet/deposits/currencies — returns enabled currencies and limits (Live from API + Admin config)
|
||||
*/
|
||||
public function currencies(Request $request)
|
||||
{
|
||||
return response()->json($this->deposits->currenciesForUser(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /wallet/deposits — start a deposit via NOWPayments
|
||||
* Body: { currency: string, amount: number|string }
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'currency' => ['required','string','max:32'],
|
||||
'amount' => ['required','numeric','min:0.00000001'], // USD/BTX in MVP
|
||||
]);
|
||||
|
||||
$res = $this->deposits->startDeposit($user, strtoupper($data['currency']), (float) $data['amount']);
|
||||
if (!$res || isset($res['error'])) {
|
||||
$err = $res['error'] ?? 'unknown_error';
|
||||
$payload = ['message' => $err];
|
||||
if ($err === 'amount_out_of_bounds') {
|
||||
$payload['min_usd'] = $res['min_usd'] ?? null;
|
||||
$payload['max_usd'] = $res['max_usd'] ?? null;
|
||||
}
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
|
||||
return response()->json($res, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wallet/deposits/history — get user's payment history
|
||||
*/
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$limit = min(50, max(1, (int) $request->query('limit', 10)));
|
||||
$history = $this->deposits->getUserHistory($user, $limit);
|
||||
|
||||
return response()->json(['data' => $history], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /wallet/deposits/{order_id} — cancel a pending deposit
|
||||
*/
|
||||
public function cancel(string $orderId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$cp = CryptoPayment::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (!$cp) {
|
||||
return response()->json(['message' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
// Only allow cancellation of pending deposits
|
||||
if (!in_array($cp->status, ['waiting', 'new', 'confirming'])) {
|
||||
return response()->json(['message' => 'This deposit can no longer be canceled.'], 422);
|
||||
}
|
||||
|
||||
$cp->status = 'canceled';
|
||||
$cp->save();
|
||||
|
||||
return response()->json(['message' => 'Deposit canceled.'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wallet/deposits/{order_id} — optional polling endpoint to see current status
|
||||
*/
|
||||
public function show(string $orderId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$cp = CryptoPayment::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (!$cp) {
|
||||
return response()->json(['message' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'order_id' => $cp->order_id,
|
||||
'invoice_id' => $cp->invoice_id,
|
||||
'payment_id' => $cp->payment_id,
|
||||
'status' => $cp->status,
|
||||
'pay_currency' => $cp->pay_currency,
|
||||
'price_amount' => (string) $cp->price_amount,
|
||||
'credited_btx' => $cp->credited_btx !== null ? (string) $cp->credited_btx : null,
|
||||
'credited_at' => $cp->credited_at?->toIso8601String(),
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
198
app/Http/Controllers/DirectMessageController.php
Normal file
198
app/Http/Controllers/DirectMessageController.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DirectMessage;
|
||||
use App\Models\DirectMessageReport;
|
||||
use App\Models\Friend;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class DirectMessageController extends Controller
|
||||
{
|
||||
private function formatUser(User $u): array
|
||||
{
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar,
|
||||
'avatar_url' => $u->avatar_url,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatMessage(DirectMessage $m): array
|
||||
{
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'message' => $m->is_deleted ? null : $m->message,
|
||||
'sender_id' => $m->sender_id,
|
||||
'is_deleted' => $m->is_deleted,
|
||||
'is_read' => $m->is_read,
|
||||
'created_at' => $m->created_at,
|
||||
'user' => $m->sender ? $this->formatUser($m->sender) : null,
|
||||
'reply_to' => $m->replyTo ? [
|
||||
'id' => $m->replyTo->id,
|
||||
'message' => $m->replyTo->is_deleted ? null : $m->replyTo->message,
|
||||
'sender_id' => $m->replyTo->sender_id,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// GET /api/dm/conversations
|
||||
public function conversations()
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$sent = DirectMessage::where('sender_id', $me)->select('receiver_id as partner_id');
|
||||
$received = DirectMessage::where('receiver_id', $me)->select('sender_id as partner_id');
|
||||
|
||||
$partnerIds = $sent->union($received)->pluck('partner_id')->unique()->values();
|
||||
|
||||
$conversations = $partnerIds->map(function ($pid) use ($me) {
|
||||
$partner = User::select('id', 'username', 'name', 'avatar', 'avatar_url')->find($pid);
|
||||
if (!$partner) return null;
|
||||
|
||||
$lastMsg = DirectMessage::where(function ($q) use ($me, $pid) {
|
||||
$q->where('sender_id', $me)->where('receiver_id', $pid);
|
||||
})->orWhere(function ($q) use ($me, $pid) {
|
||||
$q->where('sender_id', $pid)->where('receiver_id', $me);
|
||||
})->orderByDesc('created_at')->first();
|
||||
|
||||
$unread = DirectMessage::where('sender_id', $pid)
|
||||
->where('receiver_id', $me)
|
||||
->where('is_read', false)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'partner' => $this->formatUser($partner),
|
||||
'last_message' => $lastMsg ? [
|
||||
'id' => $lastMsg->id,
|
||||
'message' => $lastMsg->is_deleted ? null : $lastMsg->message,
|
||||
'sender_id' => $lastMsg->sender_id,
|
||||
'created_at' => $lastMsg->created_at,
|
||||
] : null,
|
||||
'unread_count' => $unread,
|
||||
];
|
||||
})->filter()->sortByDesc(fn ($c) => optional($c['last_message'])['created_at'])->values();
|
||||
|
||||
return response()->json(['data' => $conversations]);
|
||||
}
|
||||
|
||||
// GET /api/dm/{userId}
|
||||
public function messages($userId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$msgs = DirectMessage::where(function ($q) use ($me, $userId) {
|
||||
$q->where('sender_id', $me)->where('receiver_id', $userId);
|
||||
})->orWhere(function ($q) use ($me, $userId) {
|
||||
$q->where('sender_id', $userId)->where('receiver_id', $me);
|
||||
})
|
||||
->with(['sender:id,username,name,avatar,avatar_url', 'replyTo'])
|
||||
->orderBy('created_at')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn ($m) => $this->formatMessage($m));
|
||||
|
||||
// Mark as read
|
||||
DirectMessage::where('sender_id', $userId)
|
||||
->where('receiver_id', $me)
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true]);
|
||||
|
||||
return response()->json(['data' => $msgs]);
|
||||
}
|
||||
|
||||
// POST /api/dm/{userId}
|
||||
public function send(Request $request, $userId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:1000',
|
||||
'reply_to_id' => 'nullable|integer|exists:direct_messages,id',
|
||||
]);
|
||||
|
||||
User::findOrFail($userId);
|
||||
|
||||
$areFriends = Friend::where(function ($q) use ($me, $userId) {
|
||||
$q->where('user_id', $me)->where('friend_id', $userId);
|
||||
})->orWhere(function ($q) use ($me, $userId) {
|
||||
$q->where('user_id', $userId)->where('friend_id', $me);
|
||||
})->where('status', 'accepted')->exists();
|
||||
|
||||
if (!$areFriends) {
|
||||
return response()->json(['error' => 'You must be friends to send messages.'], 403);
|
||||
}
|
||||
|
||||
$msg = DirectMessage::create([
|
||||
'sender_id' => $me,
|
||||
'receiver_id' => $userId,
|
||||
'message' => $request->message,
|
||||
'reply_to_id' => $request->reply_to_id,
|
||||
]);
|
||||
|
||||
$msg->load(['sender:id,username,name,avatar,avatar_url', 'replyTo']);
|
||||
|
||||
return response()->json(['data' => $this->formatMessage($msg)], 201);
|
||||
}
|
||||
|
||||
// POST /api/dm/messages/{id}/report
|
||||
public function report(Request $request, $messageId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:64',
|
||||
'details' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$msg = DirectMessage::where(function ($q) use ($me) {
|
||||
$q->where('sender_id', $me)->orWhere('receiver_id', $me);
|
||||
})->findOrFail($messageId);
|
||||
|
||||
DirectMessageReport::firstOrCreate(
|
||||
['reporter_id' => $me, 'message_id' => $msg->id],
|
||||
['reason' => $request->reason, 'details' => $request->details]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// GET /api/friends
|
||||
public function friends()
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$friends = Friend::where(function ($q) use ($me) {
|
||||
$q->where('user_id', $me)->orWhere('friend_id', $me);
|
||||
})->where('status', 'accepted')
|
||||
->with(['user:id,username,name,avatar,avatar_url', 'friend:id,username,name,avatar,avatar_url'])
|
||||
->get()
|
||||
->map(function ($f) use ($me) {
|
||||
$partner = $f->user_id === $me ? $f->friend : $f->user;
|
||||
return $partner ? $this->formatUser($partner) : null;
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json(['data' => $friends]);
|
||||
}
|
||||
|
||||
// GET /api/friends/requests
|
||||
public function friendRequests()
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$requests = Friend::where('friend_id', $me)
|
||||
->where('status', 'pending')
|
||||
->with('user:id,username,name,avatar,avatar_url')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn ($f) => [
|
||||
'id' => $f->id,
|
||||
'from' => $f->user ? $this->formatUser($f->user) : null,
|
||||
'created_at' => $f->created_at,
|
||||
])->filter(fn ($r) => $r['from'] !== null)->values();
|
||||
|
||||
return response()->json(['data' => $requests]);
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/EmbedController.php
Normal file
130
app/Http/Controllers/EmbedController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EmbedController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show embedded game page.
|
||||
*
|
||||
* Requirement: For the Localhost provider (and generally safe),
|
||||
* the URL must contain a `?mode=demo` or `?mode=real` query param
|
||||
* so the correct mode is selected. If missing/invalid, we redirect
|
||||
* to the same URL with a normalized `mode` parameter.
|
||||
*/
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$rawMode = $request->query('mode');
|
||||
$normalized = null;
|
||||
if (is_string($rawMode)) {
|
||||
$val = strtolower(trim($rawMode));
|
||||
if (in_array($val, ['demo', 'real'], true)) {
|
||||
$normalized = $val;
|
||||
}
|
||||
}
|
||||
|
||||
// If mode is missing or invalid → redirect to add/fix it (default demo)
|
||||
if ($normalized === null) {
|
||||
$normalized = 'demo';
|
||||
$query = array_merge($request->query(), ['mode' => $normalized]);
|
||||
$url = $request->url() . (empty($query) ? '' : ('?' . http_build_query($query)));
|
||||
return redirect()->to($url, 302);
|
||||
}
|
||||
|
||||
// Localhost provider integration for specific games
|
||||
$slugKey = strtolower($slug);
|
||||
$supported = [
|
||||
'dice' => 'dice',
|
||||
'plinko' => 'plinko',
|
||||
'mines' => 'mines',
|
||||
];
|
||||
$base = rtrim((string) config('games.providers.local.base_url', 'http://localhost:3001/games'), '/');
|
||||
|
||||
if (array_key_exists($slugKey, $supported)) {
|
||||
$gamePath = $supported[$slugKey];
|
||||
$src = $base . '/' . $gamePath . '/index.html?mode=' . urlencode($normalized);
|
||||
|
||||
$title = htmlspecialchars("Local Provider • {$slug} ({$normalized})", ENT_QUOTES, 'UTF-8');
|
||||
$srcAttr = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
|
||||
$slugJson = json_encode($slug, JSON_UNESCAPED_SLASHES);
|
||||
$modeJson = json_encode($normalized);
|
||||
$srcJson = json_encode($src, JSON_UNESCAPED_SLASHES);
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{$title}</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#000}
|
||||
.frame{position:fixed;inset:0;border:0;width:100%;height:100%;display:block;background:#000}
|
||||
.bar{position:fixed;left:0;right:0;top:0;height:42px;background:#0b0b0b;border-bottom:1px solid #161616;display:flex;align-items:center;gap:10px;padding:0 12px;color:#d7d7d7;font:14px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif}
|
||||
.sp{flex:1}
|
||||
.pill{background:#131313;border:1px solid #1f1f1f;border-radius:999px;padding:6px 10px;color:#bbb}
|
||||
</style>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'self' http://localhost:3001 http://127.0.0.1:3001; script-src 'self' 'unsafe-inline' http://localhost:3001 http://127.0.0.1:3001; connect-src *; img-src * data: blob:; style-src 'self' 'unsafe-inline' http://localhost:3001 http://127.0.0.1:3001; media-src *;" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<div>Localhost Provider</div>
|
||||
<div class="pill">Slug: {$slugKey}</div>
|
||||
<div class="pill">Mode: {$normalized}</div>
|
||||
<div class="sp"></div>
|
||||
<div style="opacity:.6">src: {$srcAttr}</div>
|
||||
</div>
|
||||
<iframe class="frame" src="{$srcAttr}" allowfullscreen></iframe>
|
||||
<script>
|
||||
try { window.parent && window.parent.postMessage({ type: 'casino.embed.ready', provider: 'localhost', slug: {$slugJson}, mode: {$modeJson}, src: {$srcJson} }, '*'); } catch (e) {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return new Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
}
|
||||
|
||||
// Fallback minimal HTML placeholder for unknown slugs
|
||||
$slugJson = json_encode($slug, JSON_UNESCAPED_SLASHES);
|
||||
$modeJson = json_encode($normalized);
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Game Embed • {$slug} ({$normalized})</title>
|
||||
<style>
|
||||
:root{color-scheme:dark light}
|
||||
body{margin:0;background:#0b0b0b;color:#e8e8e8;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif}
|
||||
.wrap{min-height:100vh;display:grid;place-items:center}
|
||||
.card{background:#0f0f0f;border:1px solid #1b1b1b;border-radius:12px;padding:24px;max-width:840px;width:92%}
|
||||
h1{margin:0 0 8px;font-size:20px}
|
||||
.muted{opacity:.7}
|
||||
code{background:#121212;border:1px solid #1e1e1e;padding:.1em .35em;border-radius:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Embedded Game (Fallback)</h1>
|
||||
<p>Unbekannter Slug: <code>{$slug}</code></p>
|
||||
<p>Mode: <code>{$normalized}</code></p>
|
||||
<p class="muted">Unterstützte lokale Spiele: <code>dice</code>, <code>plinko</code>, <code>mines</code>. Bitte URL prüfen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
try {
|
||||
window.parent && window.parent.postMessage({ type: 'casino.embed.ready', slug: {$slugJson}, mode: {$modeJson} }, '*');
|
||||
} catch {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return new Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/FavoriteController.php
Normal file
68
app/Http/Controllers/FavoriteController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserFavorite;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/favorites — list the authenticated user's favorite games
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
$favorites = UserFavorite::where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->get(['game_slug', 'game_name', 'game_image', 'game_provider', 'created_at']);
|
||||
|
||||
return response()->json(['data' => $favorites]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites — add a game to favorites
|
||||
* Body: { slug, name?, image?, provider? }
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:128'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'image' => ['nullable', 'string', 'max:512'],
|
||||
'provider' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$fav = UserFavorite::firstOrCreate(
|
||||
['user_id' => $user->id, 'game_slug' => $data['slug']],
|
||||
[
|
||||
'game_name' => $data['name'] ?? null,
|
||||
'game_image' => $data['image'] ?? null,
|
||||
'game_provider' => $data['provider'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['data' => $fav], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites/{slug} — remove from favorites
|
||||
*/
|
||||
public function destroy(Request $request, string $slug)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
UserFavorite::where('user_id', $user->id)
|
||||
->where('game_slug', $slug)
|
||||
->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/FeedbackController.php
Normal file
105
app/Http/Controllers/FeedbackController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserFeedback;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class FeedbackController extends Controller
|
||||
{
|
||||
public function showForm()
|
||||
{
|
||||
return Inertia::render('Feedback');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category' => 'required|string|in:general,ux,mobile,feature,complaint',
|
||||
'overall_rating' => 'nullable|integer|min:1|max:5',
|
||||
'ux_rating' => 'nullable|integer|min:1|max:5',
|
||||
'comfort_rating' => 'nullable|integer|min:1|max:5',
|
||||
'mobile_rating' => 'nullable|integer|min:1|max:5',
|
||||
'uses_mobile' => 'nullable|boolean',
|
||||
'nps_score' => 'nullable|integer|min:1|max:10',
|
||||
'ux_comment' => 'nullable|string|max:2000',
|
||||
'mobile_comment' => 'nullable|string|max:2000',
|
||||
'feature_request' => 'nullable|string|max:2000',
|
||||
'improvements' => 'nullable|string|max:2000',
|
||||
'general_comment' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$data['user_id'] = auth()->id();
|
||||
|
||||
UserFeedback::create($data);
|
||||
|
||||
return back()->with('success', 'Danke für dein Feedback!');
|
||||
}
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function adminIndex(Request $request)
|
||||
{
|
||||
$status = $request->input('status', 'new');
|
||||
$search = trim($request->input('search', ''));
|
||||
|
||||
$query = UserFeedback::with('user')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
if (is_numeric($search)) {
|
||||
$query->where('id', (int) $search);
|
||||
} else {
|
||||
$query->whereHas('user', function ($q) use ($search) {
|
||||
$q->where('username', 'like', '%' . $search . '%');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$feedbacks = $query->paginate(25)->withQueryString();
|
||||
|
||||
$stats = [
|
||||
'total' => UserFeedback::count(),
|
||||
'new' => UserFeedback::where('status', 'new')->count(),
|
||||
'read' => UserFeedback::where('status', 'read')->count(),
|
||||
];
|
||||
|
||||
return Inertia::render('Admin/Feedback', [
|
||||
'feedbacks' => $feedbacks,
|
||||
'filters' => ['status' => $status, 'search' => $search],
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminShow(int $id)
|
||||
{
|
||||
$feedback = UserFeedback::with('user')->findOrFail($id);
|
||||
|
||||
if ($feedback->status === 'new') {
|
||||
$feedback->update(['status' => 'read']);
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/FeedbackShow', [
|
||||
'feedback' => $feedback,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminUpdate(Request $request, int $id)
|
||||
{
|
||||
$feedback = UserFeedback::findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => 'nullable|in:new,read',
|
||||
'admin_note' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$feedback->update(array_filter($data, fn ($v) => $v !== null));
|
||||
|
||||
return back()->with('success', 'Gespeichert.');
|
||||
}
|
||||
}
|
||||
220
app/Http/Controllers/GuildActionController.php
Normal file
220
app/Http/Controllers/GuildActionController.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Guild;
|
||||
use App\Models\GuildMember;
|
||||
use App\Models\GuildMessage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GuildActionController extends Controller
|
||||
{
|
||||
private function generateInviteCode(): string
|
||||
{
|
||||
do {
|
||||
$code = strtoupper(Str::random(8));
|
||||
} while (Guild::where('invite_code', $code)->exists());
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
if (GuildMember::where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Du bist bereits in einer Gilde.'], 422);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'min:3', 'max:40', 'unique:guilds,name'],
|
||||
'tag' => ['required', 'string', 'min:2', 'max:6', 'regex:/^[A-Za-z0-9]{2,6}$/'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'logo' => ['nullable', 'file', 'image', 'max:2048'],
|
||||
]);
|
||||
|
||||
$tag = strtoupper($validated['tag']);
|
||||
if (Guild::where('tag', $tag)->exists()) {
|
||||
return response()->json(['errors' => ['tag' => ['Dieses Tag ist bereits vergeben.']]], 422);
|
||||
}
|
||||
|
||||
$logoUrl = null;
|
||||
if ($request->hasFile('logo') && $request->file('logo')->isValid()) {
|
||||
$path = $request->file('logo')->store('guild-logos', 'public');
|
||||
$logoUrl = Storage::url($path);
|
||||
}
|
||||
|
||||
$guild = Guild::create([
|
||||
'name' => $validated['name'],
|
||||
'tag' => $tag,
|
||||
'logo_url' => $logoUrl,
|
||||
'description' => isset($validated['description']) ? strip_tags($validated['description']) : null,
|
||||
'owner_id' => $user->id,
|
||||
'invite_code' => $this->generateInviteCode(),
|
||||
'points' => 0,
|
||||
'members_count' => 1,
|
||||
]);
|
||||
|
||||
GuildMember::create([
|
||||
'guild_id' => $guild->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
GuildMessage::create([
|
||||
'guild_id' => $guild->id,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'system',
|
||||
'message' => 'hat die Gilde gegründet 🎉',
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $guild], 201);
|
||||
}
|
||||
|
||||
public function join(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
if (GuildMember::where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Du bist bereits in einer Gilde.'], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'invite_code' => ['required', 'string', 'min:4', 'max:16'],
|
||||
]);
|
||||
|
||||
$guild = Guild::where('invite_code', strtoupper($data['invite_code']))->first();
|
||||
if (!$guild) {
|
||||
return response()->json(['message' => 'Ungültiger Einladungscode.'], 422);
|
||||
}
|
||||
|
||||
GuildMember::create([
|
||||
'guild_id' => $guild->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'member',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
$guild->increment('members_count');
|
||||
|
||||
GuildMessage::create([
|
||||
'guild_id' => $guild->id,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'system',
|
||||
'message' => 'ist der Gilde beigetreten 👋',
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], 200);
|
||||
}
|
||||
|
||||
public function leave(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$member = GuildMember::where('user_id', $user->id)->first();
|
||||
if (!$member) return response()->json(['message' => 'Du bist in keiner Gilde.'], 422);
|
||||
|
||||
// Owner cannot leave — must disband or transfer first
|
||||
if ($member->role === 'owner') {
|
||||
return response()->json(['message' => 'Als Owner kannst du die Gilde nicht verlassen. Lösche die Gilde oder übertrage den Besitz.'], 422);
|
||||
}
|
||||
|
||||
$guildId = $member->guild_id;
|
||||
$member->delete();
|
||||
Guild::where('id', $guildId)->decrement('members_count');
|
||||
|
||||
return response()->json(['success' => true], 200);
|
||||
}
|
||||
|
||||
public function kick(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$actor = GuildMember::where('user_id', $user->id)->first();
|
||||
if (!$actor || !in_array($actor->role, ['owner', 'officer'])) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$target = GuildMember::where('user_id', $data['user_id'])
|
||||
->where('guild_id', $actor->guild_id)
|
||||
->first();
|
||||
|
||||
if (!$target) return response()->json(['message' => 'Mitglied nicht gefunden.'], 404);
|
||||
if ($target->role === 'owner') return response()->json(['message' => 'Den Owner kannst du nicht kicken.'], 422);
|
||||
|
||||
$target->delete();
|
||||
Guild::where('id', $actor->guild_id)->decrement('members_count');
|
||||
|
||||
return response()->json(['success' => true], 200);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$member = GuildMember::where('user_id', $user->id)->first();
|
||||
if (!$member || !in_array($member->role, ['owner', 'officer'])) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'name' => ['sometimes', 'string', 'min:3', 'max:40'],
|
||||
'tag' => ['sometimes', 'string', 'min:2', 'max:6', 'regex:/^[A-Za-z0-9]{2,6}$/'],
|
||||
'logo' => ['sometimes', 'nullable', 'file', 'image', 'max:2048'],
|
||||
]);
|
||||
|
||||
$guild = Guild::findOrFail($member->guild_id);
|
||||
|
||||
if ($request->hasFile('logo') && $request->file('logo')->isValid()) {
|
||||
// Delete old logo if stored locally
|
||||
if ($guild->logo_url && str_contains($guild->logo_url, '/storage/')) {
|
||||
$oldPath = str_replace('/storage/', 'public/', $guild->logo_url);
|
||||
Storage::delete($oldPath);
|
||||
}
|
||||
$path = $request->file('logo')->store('guild-logos', 'public');
|
||||
$guild->logo_url = Storage::url($path);
|
||||
}
|
||||
|
||||
if (isset($validated['description'])) {
|
||||
$guild->description = strip_tags($validated['description']);
|
||||
}
|
||||
if (isset($validated['name'])) {
|
||||
$guild->name = $validated['name'];
|
||||
}
|
||||
if (isset($validated['tag'])) {
|
||||
$guild->tag = strtoupper($validated['tag']);
|
||||
}
|
||||
|
||||
$guild->save();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $guild], 200);
|
||||
}
|
||||
|
||||
public function regenerateInvite(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
|
||||
$member = GuildMember::where('user_id', $user->id)->first();
|
||||
if (!$member || !in_array($member->role, ['owner', 'officer'])) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$guild = Guild::findOrFail($member->guild_id);
|
||||
$guild->update(['invite_code' => $this->generateInviteCode()]);
|
||||
|
||||
return response()->json(['invite_code' => $guild->invite_code], 200);
|
||||
}
|
||||
}
|
||||
127
app/Http/Controllers/GuildChatController.php
Normal file
127
app/Http/Controllers/GuildChatController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GuildMember;
|
||||
use App\Models\GuildMessage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class GuildChatController extends Controller
|
||||
{
|
||||
private function formatMessage(GuildMessage $m): array
|
||||
{
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'type' => $m->type ?? 'message',
|
||||
'message' => $m->is_deleted ? null : $m->message,
|
||||
'user_id' => $m->user_id,
|
||||
'is_deleted' => $m->is_deleted,
|
||||
'created_at' => $m->created_at,
|
||||
'user' => $m->user ? [
|
||||
'id' => $m->user->id,
|
||||
'username' => $m->user->username,
|
||||
'avatar' => $m->user->avatar ?? $m->user->avatar_url,
|
||||
] : null,
|
||||
'reply_to' => $m->replyTo ? [
|
||||
'id' => $m->replyTo->id,
|
||||
'message' => $m->replyTo->is_deleted ? null : $m->replyTo->message,
|
||||
'user_id' => $m->replyTo->user_id,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// GET /api/guild-chat/me
|
||||
public function myGuild()
|
||||
{
|
||||
$me = Auth::id();
|
||||
$member = GuildMember::where('user_id', $me)->with('guild')->first();
|
||||
|
||||
if (!$member || !$member->guild) {
|
||||
return response()->json(['data' => null]);
|
||||
}
|
||||
|
||||
$guild = $member->guild;
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $guild->id,
|
||||
'name' => $guild->name,
|
||||
'tag' => $guild->tag,
|
||||
'logo_url' => $guild->logo_url,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/guild-chat/{guildId}
|
||||
public function messages($guildId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||
if (!$isMember) {
|
||||
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||
}
|
||||
|
||||
$msgs = GuildMessage::where('guild_id', $guildId)
|
||||
->with(['user:id,username,avatar,avatar_url', 'replyTo'])
|
||||
->orderBy('created_at')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn ($m) => $this->formatMessage($m));
|
||||
|
||||
return response()->json(['data' => $msgs]);
|
||||
}
|
||||
|
||||
// POST /api/guild-chat/{guildId}
|
||||
public function send(Request $request, $guildId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||
if (!$isMember) {
|
||||
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:1000',
|
||||
'reply_to_id' => 'nullable|integer|exists:guild_messages,id',
|
||||
]);
|
||||
|
||||
$msg = GuildMessage::create([
|
||||
'guild_id' => $guildId,
|
||||
'user_id' => $me,
|
||||
'type' => 'message',
|
||||
'message' => $request->message,
|
||||
'reply_to_id' => $request->reply_to_id,
|
||||
]);
|
||||
|
||||
$msg->load(['user:id,username,avatar,avatar_url', 'replyTo']);
|
||||
|
||||
return response()->json(['data' => $this->formatMessage($msg)], 201);
|
||||
}
|
||||
|
||||
// GET /api/guild-chat/{guildId}/members
|
||||
public function members($guildId)
|
||||
{
|
||||
$me = Auth::id();
|
||||
|
||||
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||
if (!$isMember) {
|
||||
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||
}
|
||||
|
||||
$members = GuildMember::where('guild_id', $guildId)
|
||||
->with('user:id,username,avatar,avatar_url')
|
||||
->orderByRaw("FIELD(role, 'owner', 'officer', 'member')")
|
||||
->get()
|
||||
->map(fn ($m) => [
|
||||
'id' => $m->user->id,
|
||||
'username' => $m->user->username,
|
||||
'avatar' => $m->user->avatar ?? $m->user->avatar_url,
|
||||
'role' => $m->role,
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $members]);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/GuildController.php
Normal file
101
app/Http/Controllers/GuildController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Guild;
|
||||
use App\Models\GuildMember;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class GuildController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
return Inertia::render('guilds/Index', [
|
||||
'guild' => null,
|
||||
'myRole' => null,
|
||||
'canManage' => false,
|
||||
'invite' => null,
|
||||
'topPlayers' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$member = GuildMember::where('user_id', $user->id)->first();
|
||||
|
||||
if (!$member) {
|
||||
return Inertia::render('guilds/Index', [
|
||||
'guild' => null,
|
||||
'myRole' => null,
|
||||
'canManage' => false,
|
||||
'invite' => null,
|
||||
'topPlayers' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$guild = Guild::with(['owner:id,username,avatar_url', 'members:id,username,avatar_url'])
|
||||
->findOrFail($member->guild_id);
|
||||
|
||||
$myRole = $member->role;
|
||||
$canManage = in_array($myRole, ['owner', 'officer']);
|
||||
|
||||
// Build member list with pivot data
|
||||
$memberRows = GuildMember::where('guild_id', $guild->id)
|
||||
->with('user:id,username,avatar_url')
|
||||
->get()
|
||||
->map(fn ($m) => [
|
||||
'id' => $m->user_id,
|
||||
'username' => $m->user?->username,
|
||||
'avatar_url'=> $m->user?->avatar_url,
|
||||
'role' => $m->role,
|
||||
'joined_at' => $m->joined_at?->toIso8601String(),
|
||||
'wagered' => (float) ($m->wagered ?? 0),
|
||||
]);
|
||||
|
||||
$guildData = [
|
||||
'id' => $guild->id,
|
||||
'name' => $guild->name,
|
||||
'tag' => $guild->tag,
|
||||
'logo_url' => $guild->logo_url,
|
||||
'description' => $guild->description,
|
||||
'points' => $guild->points,
|
||||
'members_count'=> $guild->members_count,
|
||||
'owner' => [
|
||||
'id' => $guild->owner->id,
|
||||
'username' => $guild->owner->username,
|
||||
'avatar_url'=> $guild->owner->avatar_url,
|
||||
],
|
||||
'members' => $memberRows,
|
||||
];
|
||||
|
||||
return Inertia::render('guilds/Index', [
|
||||
'guild' => $guildData,
|
||||
'myRole' => $myRole,
|
||||
'canManage' => $canManage,
|
||||
'invite' => $canManage ? $guild->invite_code : null,
|
||||
'topPlayers' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function top(Request $request)
|
||||
{
|
||||
$guilds = Guild::with('owner:id,username')
|
||||
->orderByDesc('points')
|
||||
->orderByDesc('members_count')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn ($g) => [
|
||||
'id' => $g->id,
|
||||
'name' => $g->name,
|
||||
'tag' => $g->tag,
|
||||
'logo_url' => $g->logo_url,
|
||||
'points' => $g->points,
|
||||
'members_count'=> $g->members_count,
|
||||
'owner' => ['id' => $g->owner?->id, 'username' => $g->owner?->username],
|
||||
]);
|
||||
|
||||
return Inertia::render('guilds/Top', ['guilds' => $guilds]);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/LocaleController.php
Normal file
48
app/Http/Controllers/LocaleController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class LocaleController extends Controller
|
||||
{
|
||||
/** @var array<string> */
|
||||
private array $available = [
|
||||
'en','de','es','pt_BR','tr','pl',
|
||||
'fr','it','ru','uk','vi','id','zh_CN','ja','ko','sv','no','fi','nl',
|
||||
];
|
||||
|
||||
public function set(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'locale' => ['required','string','max:8'],
|
||||
]);
|
||||
$code = $this->normalize($data['locale']);
|
||||
if (!in_array($code, $this->available, true)) {
|
||||
return response()->json(['message' => 'Unsupported locale.'], 422);
|
||||
}
|
||||
|
||||
// Persist to session and cookie (1 year)
|
||||
$request->session()->put('locale', $code);
|
||||
Cookie::queue(cookie('locale', $code, 60 * 24 * 365));
|
||||
|
||||
// Update user preference if logged in (no local DB writes in gateway)
|
||||
if ($user = $request->user()) {
|
||||
if (($user->preferred_locale ?? null) !== $code) {
|
||||
// Defer persistence to external API if needed; here we only keep session/cookie.
|
||||
// Optionally enqueue an event to sync upstream.
|
||||
}
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function normalize(string $code): string
|
||||
{
|
||||
$code = str_replace([' ', '-'], ['','_'], trim($code));
|
||||
if (strtolower($code) === 'pt_br') return 'pt_BR';
|
||||
if (strtolower($code) === 'zh_cn') return 'zh_CN';
|
||||
return strtolower($code);
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/NowPaymentsWebhookController.php
Normal file
27
app/Http/Controllers/NowPaymentsWebhookController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\DepositService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NowPaymentsWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly DepositService $deposits)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/nowpayments — public IPN endpoint
|
||||
* - CSRF should be disabled via api middleware group
|
||||
* - Validates HMAC signature and processes deposit crediting
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$out = $this->deposits->handleIpn($request);
|
||||
return response()->json([
|
||||
'ok' => (bool) ($out['ok'] ?? false),
|
||||
'message' => $out['message'] ?? 'ok',
|
||||
], (int) ($out['status'] ?? 200));
|
||||
}
|
||||
}
|
||||
112
app/Http/Controllers/OperatorController.php
Normal file
112
app/Http/Controllers/OperatorController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\OperatorCasino;
|
||||
use App\Models\OperatorSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OperatorController extends Controller
|
||||
{
|
||||
// Supported game slugs
|
||||
private const VALID_GAMES = ['dice', 'crash', 'mines', 'plinko'];
|
||||
|
||||
/**
|
||||
* GET /operator/games
|
||||
*
|
||||
* Returns the full game catalog with thumbnail URLs and launch paths.
|
||||
*/
|
||||
public function games(Request $request)
|
||||
{
|
||||
$baseUrl = rtrim((string) config('games.game_base_url', config('app.url')), '/');
|
||||
|
||||
$games = collect(config('games.catalog', []))->map(fn ($g) => array_merge($g, [
|
||||
'thumbnail_url' => "{$baseUrl}/assets/games/{$g['slug']}.png",
|
||||
'launch_path' => "/{$g['slug']}",
|
||||
]));
|
||||
|
||||
return response()->json(['games' => $games]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /operator/launch
|
||||
*
|
||||
* Creates a new operator game session and returns the launch URL.
|
||||
*/
|
||||
public function launch(Request $request)
|
||||
{
|
||||
/** @var OperatorCasino $casino */
|
||||
$casino = $request->attributes->get('operator_casino');
|
||||
|
||||
$data = $request->validate([
|
||||
'player_id' => ['required', 'string', 'max:255'],
|
||||
'balance' => ['required', 'numeric', 'min:0'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'game' => ['required', 'string', 'in:' . implode(',', self::VALID_GAMES)],
|
||||
// license_key is consumed by middleware; allow it in the body without failing validation
|
||||
'license_key' => ['sometimes', 'string'],
|
||||
]);
|
||||
|
||||
// Generate server seed for provably-fair and store it encrypted
|
||||
$serverSeed = bin2hex(random_bytes(32));
|
||||
$serverSeedHash = hash('sha256', $serverSeed);
|
||||
|
||||
$token = (string) Str::uuid();
|
||||
$expiresAt = now()->addHours(4);
|
||||
|
||||
OperatorSession::create([
|
||||
'session_token' => $token,
|
||||
'operator_casino_id' => $casino->id,
|
||||
'player_id' => $data['player_id'],
|
||||
'game_slug' => $data['game'],
|
||||
'currency' => strtoupper($data['currency']),
|
||||
'start_balance' => $data['balance'],
|
||||
'current_balance' => $data['balance'],
|
||||
'server_seed' => encrypt($serverSeed),
|
||||
'server_seed_hash' => $serverSeedHash,
|
||||
'status' => 'active',
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$baseUrl = rtrim((string) config('games.game_base_url', config('app.url')), '/');
|
||||
$launchUrl = "{$baseUrl}/{$data['game']}?session={$token}";
|
||||
|
||||
return response()->json([
|
||||
'session_token' => $token,
|
||||
'launch_url' => $launchUrl,
|
||||
'server_seed_hash' => $serverSeedHash,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /operator/session/{token}
|
||||
*
|
||||
* Returns the current state of a session — including the final balance delta.
|
||||
* The casino should call this after the player's session ends.
|
||||
*/
|
||||
public function session(Request $request, string $token)
|
||||
{
|
||||
/** @var OperatorCasino $casino */
|
||||
$casino = $request->attributes->get('operator_casino');
|
||||
|
||||
$session = OperatorSession::where('session_token', $token)
|
||||
->where('operator_casino_id', $casino->id)
|
||||
->firstOrFail();
|
||||
|
||||
$session->expireIfNeeded();
|
||||
|
||||
return response()->json([
|
||||
'session_token' => $session->session_token,
|
||||
'player_id' => $session->player_id,
|
||||
'game' => $session->game_slug,
|
||||
'currency' => $session->currency,
|
||||
'start_balance' => (float) $session->start_balance,
|
||||
'current_balance' => (float) $session->current_balance,
|
||||
'balance_delta' => round((float) $session->current_balance - (float) $session->start_balance, 4),
|
||||
'status' => $session->status,
|
||||
'expires_at' => $session->expires_at->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/PromoController.php
Normal file
51
app/Http/Controllers/PromoController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class PromoController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a promo code for the authenticated user via external API.
|
||||
* Request: { code: string }
|
||||
*/
|
||||
public function apply(Request $request)
|
||||
{
|
||||
$data = Validator::make($request->all(), [
|
||||
'code' => ['required','string','max:64'],
|
||||
])->validate();
|
||||
|
||||
$code = strtoupper(trim($data['code']));
|
||||
try {
|
||||
$res = $this->client->post($request, '/promos/apply', [ 'code' => $code ]);
|
||||
if ($res->successful()) {
|
||||
$body = $res->json() ?: [];
|
||||
// Backward compatibility: ensure a message key exists
|
||||
if (!isset($body['message'])) {
|
||||
$body['message'] = 'Promo applied successfully.';
|
||||
}
|
||||
// PromoControllerTest expects { success: true, message: '...' }
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $body['message']
|
||||
], 200);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/RecentlyPlayedController.php
Normal file
35
app/Http/Controllers/RecentlyPlayedController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GameBet;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RecentlyPlayedController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/recently-played
|
||||
* Returns up to 8 distinct recently played games for the authenticated user.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
// Use a subquery to get the latest bet per game_name
|
||||
$games = GameBet::where('user_id', $user->id)
|
||||
->select('game_name', \Illuminate\Support\Facades\DB::raw('MAX(created_at) as last_played_at'))
|
||||
->groupBy('game_name')
|
||||
->orderByDesc('last_played_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn($row) => [
|
||||
'game_name' => $row->game_name,
|
||||
'slug' => strtolower(preg_replace('/[^a-z0-9]+/i', '-', $row->game_name)),
|
||||
'last_played_at' => $row->last_played_at,
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $games]);
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Settings/KycController.php
Normal file
136
app/Http/Controllers/Settings/KycController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class KycController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Show KYC center page with user's documents (from upstream)
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$docs = [];
|
||||
try {
|
||||
$res = $this->client->get($request, '/kyc/documents', [], retry: true);
|
||||
if ($res->successful()) {
|
||||
$j = $res->json() ?: [];
|
||||
$docs = $j['data'] ?? $j['documents'] ?? $j;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore; page can still render and show empty state
|
||||
}
|
||||
|
||||
return Inertia::render('settings/Kyc', [
|
||||
'documents' => $docs,
|
||||
'accepted' => [
|
||||
'identity' => ['passport','driver_license','id_card','other'],
|
||||
'address' => ['bank_statement','utility_bill','other'],
|
||||
'payment' => ['online_banking','other'],
|
||||
],
|
||||
'maxUploadMb' => 15,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new KYC document via upstream (multipart)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category' => ['required', Rule::in(['identity','address','payment'])],
|
||||
'type' => ['required', Rule::in(['passport','driver_license','id_card','bank_statement','utility_bill','online_banking','other'])],
|
||||
'file' => ['required','file','max:15360', 'mimetypes:image/jpeg,image/png,image/webp,application/pdf'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$res = $this->client->postMultipart($request, '/kyc/documents', [
|
||||
'category' => $validated['category'],
|
||||
'type' => $validated['type'],
|
||||
], $request->file('file'), 'file');
|
||||
|
||||
if ($res->successful()) {
|
||||
return back()->with('status', 'Document uploaded');
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||
return back()->withErrors(['kyc' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||
}
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a KYC document via upstream
|
||||
*/
|
||||
public function destroy(Request $request, int $docId)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->delete($request, "/kyc/documents/{$docId}");
|
||||
if ($res->successful()) {
|
||||
return back()->with('status', 'Document deleted');
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||
return back()->withErrors(['kyc' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||
}
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a document via upstream: prefer redirect to signed URL if provided
|
||||
*/
|
||||
public function download(Request $request, int $docId)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->get($request, "/kyc/documents/{$docId}/download", [], retry: false);
|
||||
if ($res->successful()) {
|
||||
$j = $res->json();
|
||||
$url = $j['url'] ?? null;
|
||||
if ($url) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
// If upstream responds with binary directly, just passthrough headers/body
|
||||
$content = $res->body();
|
||||
$headers = [
|
||||
'Content-Type' => $res->header('Content-Type', 'application/octet-stream'),
|
||||
];
|
||||
return response($content, 200, $headers);
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||
return back()->withErrors(['kyc' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||
}
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's password settings page.
|
||||
*/
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('settings/Password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(PasswordUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->update([
|
||||
'password' => $request->password,
|
||||
]);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/Profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's profile.
|
||||
*/
|
||||
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Settings/SecurityController.php
Normal file
70
app/Http/Controllers/Settings/SecurityController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Render the Security center page.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Provide a light payload; sessions loaded via separate endpoint
|
||||
return Inertia::render('settings/Security', [
|
||||
'twoFactorEnabled' => (bool) optional($request->user())->hasEnabledTwoFactorAuthentication(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List active sessions for the current user (from database sessions table).
|
||||
*/
|
||||
public function sessions(Request $request)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$rows = DB::table('sessions')
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('last_activity')
|
||||
->limit(100)
|
||||
->get(['id', 'ip_address', 'user_agent', 'last_activity']);
|
||||
|
||||
// Format response
|
||||
$data = $rows->map(function ($r) use ($request) {
|
||||
$isCurrent = $request->session()->getId() === $r->id;
|
||||
return [
|
||||
'id' => $r->id,
|
||||
'ip' => $r->ip_address,
|
||||
'user_agent' => $r->user_agent,
|
||||
'last_activity' => $r->last_activity,
|
||||
'current' => $isCurrent,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session by ID (current user's session only)
|
||||
*/
|
||||
public function revoke(Request $request, string $id)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$session = DB::table('sessions')->where('id', $id)->first();
|
||||
if (! $session || $session->user_id != $userId) {
|
||||
abort(404);
|
||||
}
|
||||
// Prevent revoking current session via this endpoint to avoid lockouts
|
||||
if ($request->session()->getId() === $id) {
|
||||
return response()->json(['message' => 'Cannot revoke current session via API.'], 422);
|
||||
}
|
||||
DB::table('sessions')->where('id', $id)->delete();
|
||||
return response()->json(['message' => 'Session revoked']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Illuminate\Routing\Controllers\Middleware;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
|
||||
{
|
||||
/**
|
||||
* Get the middleware that should be assigned to the controller.
|
||||
*/
|
||||
public static function middleware(): array
|
||||
{
|
||||
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
|
||||
? [new Middleware('password.confirm', only: ['show'])]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user's two-factor authentication settings page.
|
||||
*/
|
||||
public function show(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
$request->ensureStateIsValid();
|
||||
|
||||
return Inertia::render('settings/TwoFactor', [
|
||||
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
|
||||
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
377
app/Http/Controllers/SocialController.php
Normal file
377
app/Http/Controllers/SocialController.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Friend;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\ProfileLike;
|
||||
use App\Models\ProfileReport;
|
||||
use App\Models\Tip;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
// Profile page
|
||||
public function show(string $username)
|
||||
{
|
||||
$user = User::query()
|
||||
->where('username', $username)
|
||||
->first();
|
||||
|
||||
if (!$user) {
|
||||
abort(404, 'User not found');
|
||||
}
|
||||
|
||||
$auth = Auth::user();
|
||||
$authId = $auth?->id;
|
||||
|
||||
$likesCount = ProfileLike::where('profile_id', $user->id)->count();
|
||||
$hasLiked = $authId ? ProfileLike::where('profile_id', $user->id)->where('user_id', $authId)->exists() : false;
|
||||
|
||||
$comments = ProfileComment::with(['user:id,username,avatar'])
|
||||
->where('profile_id', $user->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'user' => [
|
||||
'id' => $c->user->id,
|
||||
'username' => $c->user->username,
|
||||
'avatar' => $c->user->avatar,
|
||||
],
|
||||
'content' => $c->content,
|
||||
'created_at' => $c->created_at,
|
||||
];
|
||||
});
|
||||
|
||||
// Friendship flags
|
||||
$isFriend = false;
|
||||
$isPending = false; // I sent them a request (outgoing)
|
||||
$theyRequestedMe = false; // They sent me a request (incoming — I can accept/decline)
|
||||
$friendRowId = null;
|
||||
if ($authId) {
|
||||
$friendRow = Friend::where(function ($q) use ($authId, $user) {
|
||||
$q->where('user_id', $authId)->where('friend_id', $user->id);
|
||||
})->orWhere(function ($q) use ($authId, $user) {
|
||||
$q->where('user_id', $user->id)->where('friend_id', $authId);
|
||||
})->first();
|
||||
if ($friendRow) {
|
||||
$isFriend = $friendRow->status === 'accepted';
|
||||
$isPending = $friendRow->status === 'pending' && $friendRow->user_id == $authId;
|
||||
$theyRequestedMe = $friendRow->status === 'pending' && $friendRow->user_id == $user->id;
|
||||
$friendRowId = $friendRow->id;
|
||||
}
|
||||
}
|
||||
|
||||
$profile = [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'avatar' => $user->avatar ?? $user->avatar_url,
|
||||
'banner' => $user->banner,
|
||||
'bio' => $user->bio,
|
||||
'vip_level' => (int) ($user->vip_level ?? 0),
|
||||
'role' => $user->role ?? 'User',
|
||||
'clan_tag' => $user->clan_tag,
|
||||
'stats' => [
|
||||
'wagered' => (float) ($user->stats?->total_wagered ?? 0),
|
||||
'wins' => (int) ($user->stats?->total_wins ?? 0),
|
||||
'losses' => null,
|
||||
'biggest_win' => (float) ($user->stats?->biggest_win ?? 0),
|
||||
'biggest_win_game' => $user->stats?->biggest_win_game ?? null,
|
||||
'join_date' => optional($user->created_at)->toDateString(),
|
||||
'likes_count' => $likesCount,
|
||||
],
|
||||
'best_wins' => [],
|
||||
'comments' => $comments,
|
||||
];
|
||||
|
||||
return Inertia::render('Social/Profile', [
|
||||
'profile' => $profile,
|
||||
'isOwnProfile' => $authId ? ((int)$authId === (int)$user->id) : false,
|
||||
'isFriend' => $isFriend,
|
||||
'isPending' => $isPending,
|
||||
'theyRequestedMe' => $theyRequestedMe,
|
||||
'friendRowId' => $friendRowId,
|
||||
'hasLiked' => $hasLiked,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update own profile
|
||||
public function update(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'is_public' => 'boolean',
|
||||
'bio' => 'nullable|string|max:160',
|
||||
'avatar' => 'nullable|string',
|
||||
'banner' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$user->fill([
|
||||
'is_public' => (bool) ($data['is_public'] ?? $user->is_public),
|
||||
'bio' => $data['bio'] ?? $user->bio,
|
||||
'avatar' => $data['avatar'] ?? $user->avatar,
|
||||
'banner' => $data['banner'] ?? $user->banner,
|
||||
])->save();
|
||||
|
||||
return back()->with('success', 'Profile updated.');
|
||||
}
|
||||
|
||||
// Upload avatar or banner
|
||||
public function uploadImage(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$request->validate([
|
||||
'type' => 'required|in:avatar,banner',
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,gif,webp,bmp|max:5120',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$type = $request->input('type');
|
||||
$path = $file->store("profile/{$type}s", 'public');
|
||||
$url = Storage::disk('public')->url($path);
|
||||
|
||||
if ($type === 'banner') {
|
||||
$user->banner = $url;
|
||||
} else {
|
||||
$user->avatar = $url;
|
||||
}
|
||||
$user->save();
|
||||
|
||||
return response()->json(['url' => $url], 200);
|
||||
}
|
||||
|
||||
// Toggle like on a profile
|
||||
public function like($id)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$target = User::findOrFail($id);
|
||||
if ($target->id === $me->id) {
|
||||
return back();
|
||||
}
|
||||
$like = ProfileLike::where('user_id', $me->id)->where('profile_id', $target->id)->first();
|
||||
if ($like) {
|
||||
$like->delete();
|
||||
} else {
|
||||
ProfileLike::create(['user_id' => $me->id, 'profile_id' => $target->id]);
|
||||
}
|
||||
return back();
|
||||
}
|
||||
|
||||
// Add a comment to a profile
|
||||
public function comment(Request $request, $id)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$data = $request->validate(['content' => 'required|string|max:500']);
|
||||
$target = User::findOrFail($id);
|
||||
ProfileComment::create([
|
||||
'user_id' => $me->id,
|
||||
'profile_id' => $target->id,
|
||||
'content' => $data['content'],
|
||||
]);
|
||||
return back()->with('success', 'Comment posted.');
|
||||
}
|
||||
|
||||
// Report a profile
|
||||
public function report(Request $request, $id)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$data = $request->validate([
|
||||
'reason' => 'required|string',
|
||||
'details' => 'nullable|string',
|
||||
'snapshot' => 'nullable|array',
|
||||
'screenshot' => 'nullable|string',
|
||||
]);
|
||||
$target = User::findOrFail($id);
|
||||
|
||||
$screenshotPath = null;
|
||||
if (!empty($data['screenshot'])) {
|
||||
$base64 = $data['screenshot'];
|
||||
// Strip data URI prefix if present
|
||||
if (str_contains($base64, ',')) {
|
||||
$base64 = explode(',', $base64, 2)[1];
|
||||
}
|
||||
$decoded = base64_decode($base64, true);
|
||||
if ($decoded !== false) {
|
||||
$filename = 'reports/profile-screenshots/' . $target->id . '_' . time() . '.png';
|
||||
Storage::disk('public')->put($filename, $decoded);
|
||||
$screenshotPath = $filename;
|
||||
}
|
||||
}
|
||||
|
||||
ProfileReport::create([
|
||||
'reporter_id' => $me->id,
|
||||
'profile_id' => $target->id,
|
||||
'reason' => $data['reason'],
|
||||
'details' => $data['details'] ?? null,
|
||||
'snapshot' => $data['snapshot'] ?? null,
|
||||
'screenshot_path' => $screenshotPath,
|
||||
]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
return back()->with('success', 'Profile reported.');
|
||||
}
|
||||
|
||||
// User search
|
||||
public function search(Request $request)
|
||||
{
|
||||
$q = (string) $request->input('q', '');
|
||||
if (strlen($q) < 1) return response()->json([]);
|
||||
|
||||
$users = User::query()
|
||||
->where('username', 'like', "%" . str_replace(['%','_'], ['\\%','\\_'], $q) . "%")
|
||||
->orWhere('id', '=', $q)
|
||||
->orderBy('username')
|
||||
->limit(10)
|
||||
->get(['id', 'username', 'avatar', 'avatar_url', 'vip_level', 'balance']);
|
||||
|
||||
$out = $users->map(function ($u) {
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'avatar' => $u->avatar ?? $u->avatar_url,
|
||||
'avatar_url' => $u->avatar_url ?? $u->avatar,
|
||||
'vip_level' => (int) ($u->vip_level ?? 0),
|
||||
'stats' => [
|
||||
'wager' => 0, // Placeholder
|
||||
'wins' => 0, // Placeholder
|
||||
'balance' => $u->balance
|
||||
]
|
||||
];
|
||||
});
|
||||
return response()->json($out->all(), 200);
|
||||
}
|
||||
|
||||
// Send a tip (balance transfer) and record it
|
||||
public function tip(Request $request, $id)
|
||||
{
|
||||
$sender = Auth::user();
|
||||
abort_unless($sender, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'currency' => 'required|string|max:10',
|
||||
'amount' => 'required|numeric|min:0.00000001',
|
||||
'note' => 'nullable|string|max:140',
|
||||
]);
|
||||
|
||||
if ((int)$id === (int)$sender->id) {
|
||||
return back()->withErrors(['amount' => 'You cannot tip yourself.']);
|
||||
}
|
||||
|
||||
$receiver = User::findOrFail($id);
|
||||
$amount = (float) $data['amount'];
|
||||
|
||||
if ($sender->balance < $amount) {
|
||||
return back()->withErrors(['amount' => 'Insufficient balance']);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($sender, $receiver, $amount, $data) {
|
||||
// Simple balance transfer using users.balance
|
||||
$sender->balance = (float) $sender->balance - $amount;
|
||||
$receiver->balance = (float) $receiver->balance + $amount;
|
||||
$sender->save();
|
||||
$receiver->save();
|
||||
|
||||
Tip::create([
|
||||
'from_user_id' => $sender->id,
|
||||
'to_user_id' => $receiver->id,
|
||||
'currency' => strtoupper($data['currency']),
|
||||
'amount' => $amount,
|
||||
'note' => $data['note'] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Tip sent successfully.');
|
||||
}
|
||||
|
||||
// --- Friends ---
|
||||
public function requestFriend(Request $request)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer',
|
||||
]);
|
||||
$targetId = (int) $data['user_id'];
|
||||
if ($targetId === (int)$me->id) {
|
||||
return back()->withErrors(['friend' => 'You cannot add yourself.']);
|
||||
}
|
||||
$target = User::findOrFail($targetId);
|
||||
|
||||
$existing = Friend::where('user_id', $me->id)->where('friend_id', $target->id)->first();
|
||||
if ($existing) {
|
||||
if ($existing->status === 'pending') {
|
||||
return back()->with('success', 'Friend request already sent.');
|
||||
}
|
||||
if ($existing->status === 'accepted') {
|
||||
return back()->with('success', 'You are already friends.');
|
||||
}
|
||||
}
|
||||
Friend::updateOrCreate([
|
||||
'user_id' => $me->id,
|
||||
'friend_id' => $target->id,
|
||||
], [ 'status' => 'pending' ]);
|
||||
|
||||
return back()->with('success', 'Friend request sent.');
|
||||
}
|
||||
|
||||
public function acceptFriend($id)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$requestRow = Friend::where('user_id', $id)->where('friend_id', $me->id)->where('status', 'pending')->first();
|
||||
if (!$requestRow) {
|
||||
return back()->withErrors(['friend' => 'Request not found.']);
|
||||
}
|
||||
DB::transaction(function () use ($requestRow, $me, $id) {
|
||||
$requestRow->status = 'accepted';
|
||||
$requestRow->save();
|
||||
// Ensure reciprocal row
|
||||
Friend::updateOrCreate([
|
||||
'user_id' => $me->id,
|
||||
'friend_id' => (int) $id,
|
||||
], [ 'status' => 'accepted' ]);
|
||||
});
|
||||
return back()->with('success', 'Friend request accepted.');
|
||||
}
|
||||
|
||||
public function declineFriend($id)
|
||||
{
|
||||
$me = Auth::user();
|
||||
abort_unless($me, 403);
|
||||
$requestRow = Friend::where('user_id', $id)->where('friend_id', $me->id)->where('status', 'pending')->first();
|
||||
if (!$requestRow) {
|
||||
return back()->withErrors(['friend' => 'Request not found.']);
|
||||
}
|
||||
$requestRow->delete();
|
||||
return back()->with('success', 'Friend request declined.');
|
||||
}
|
||||
|
||||
public function hub()
|
||||
{
|
||||
return Inertia::render('Social/Hub');
|
||||
}
|
||||
|
||||
public function me()
|
||||
{
|
||||
return Inertia::render('Social/Settings', [
|
||||
'user' => Auth::user(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
400
app/Http/Controllers/SupportChatController.php
Normal file
400
app/Http/Controllers/SupportChatController.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupportChatController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'support_chat';
|
||||
|
||||
private function ensureEnabled()
|
||||
{
|
||||
// Use config/env directly to avoid cache/DB dependency if possible,
|
||||
// or assume cache is file-based (which is fine).
|
||||
$flag = cache()->get('support_chat_enabled');
|
||||
$enabled = is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
||||
if (!$enabled) {
|
||||
abort(503, 'Support chat is currently unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
private function isChatEnabled(): bool
|
||||
{
|
||||
$flag = cache()->get('support_chat_enabled');
|
||||
return is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
||||
}
|
||||
|
||||
private function sessionState(Request $request): array
|
||||
{
|
||||
$state = $request->session()->get(self::SESSION_KEY, [
|
||||
'thread_id' => null,
|
||||
'status' => 'new',
|
||||
'topic' => null,
|
||||
'messages' => [],
|
||||
'data_access_granted' => false,
|
||||
]);
|
||||
|
||||
if (!empty($state['thread_id'])) {
|
||||
$cached = cache()->get('support_threads:'.$state['thread_id']);
|
||||
if (is_array($cached)) {
|
||||
$state['status'] = $cached['status'] ?? $state['status'];
|
||||
foreach ($cached['messages'] ?? [] as $msg) {
|
||||
if (($msg['sender'] ?? '') === 'agent') {
|
||||
$state['status'] = 'agent';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (count($cached['messages'] ?? []) > count($state['messages'])) {
|
||||
$state['messages'] = $cached['messages'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function saveState(Request $request, array $state): void
|
||||
{
|
||||
$state['messages'] = array_slice($state['messages'], -100);
|
||||
$state['updated_at'] = now()->toIso8601String();
|
||||
$request->session()->put(self::SESSION_KEY, $state);
|
||||
$this->persistThread($request, $state);
|
||||
}
|
||||
|
||||
private function persistThread(Request $request, array $state): void
|
||||
{
|
||||
if (empty($state['thread_id'])) return;
|
||||
$user = $request->user();
|
||||
|
||||
// Construct user data safely without assuming local DB columns exist if Auth is mocked
|
||||
$userData = null;
|
||||
if ($user) {
|
||||
$userData = [
|
||||
'id' => $user->id ?? 'guest',
|
||||
'username' => $user->username ?? $user->name ?? 'Guest',
|
||||
'email' => $user->email ?? '',
|
||||
'avatar_url' => $user->avatar_url ?? $user->profile_photo_url ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$record = [
|
||||
'id' => $state['thread_id'],
|
||||
'status' => $state['status'] ?? 'new',
|
||||
'topic' => $state['topic'] ?? null,
|
||||
'user' => $userData,
|
||||
'messages' => $state['messages'] ?? [],
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
// Cache is file/redis based, so this is fine (no SQL DB)
|
||||
cache()->put('support_threads:'.$state['thread_id'], $record, now()->addDay());
|
||||
|
||||
$index = cache()->get('support_threads_index', []);
|
||||
$found = false;
|
||||
foreach ($index as &$row) {
|
||||
if (($row['id'] ?? null) === $record['id']) {
|
||||
$row['updated_at'] = $record['updated_at'];
|
||||
$row['status'] = $record['status'];
|
||||
$row['topic'] = $record['topic'];
|
||||
$row['user'] = $record['user'];
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
$index[] = [
|
||||
'id' => $record['id'],
|
||||
'updated_at' => $record['updated_at'],
|
||||
'status' => $record['status'],
|
||||
'topic' => $record['topic'],
|
||||
'user' => $record['user'],
|
||||
];
|
||||
}
|
||||
cache()->put('support_threads_index', $index, now()->addDay());
|
||||
}
|
||||
|
||||
public function start(Request $request)
|
||||
{
|
||||
// ensureEnabled removed here to allow handoff logic
|
||||
$user = $request->user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
$data = $request->validate(['topic' => 'nullable|string|max:60']);
|
||||
$enabled = $this->isChatEnabled();
|
||||
|
||||
$state = $this->sessionState($request);
|
||||
if (!$state['thread_id']) {
|
||||
$state['thread_id'] = (string) Str::uuid();
|
||||
$state['status'] = $enabled ? 'ai' : 'handoff';
|
||||
$state['topic'] = $data['topic'] ?? null;
|
||||
$state['messages'] = [];
|
||||
$state['data_access_granted'] = false;
|
||||
$state['user'] = [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username ?? $user->name,
|
||||
'email' => $user->email
|
||||
];
|
||||
|
||||
if (!empty($data['topic'])) {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => 'Thema gewählt: ' . $data['topic'], 'at' => now()->toIso8601String()];
|
||||
|
||||
if ($enabled) {
|
||||
$aiReply = $this->askOllama($state);
|
||||
if ($aiReply) {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||
} else {
|
||||
// Ollama offline or returned nothing — fall back to human handoff
|
||||
$state['status'] = 'handoff';
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Unser KI-Assistent ist gerade nicht erreichbar. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
||||
}
|
||||
} else {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Der KI-Assistent ist derzeit inaktiv. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
public function close(Request $request)
|
||||
{
|
||||
abort_unless(Auth::check(), 401);
|
||||
$state = $this->sessionState($request);
|
||||
|
||||
if (!empty($state['thread_id'])) {
|
||||
cache()->forget('support_threads:'.$state['thread_id']);
|
||||
$index = cache()->get('support_threads_index', []);
|
||||
$index = array_filter($index, fn($item) => $item['id'] !== $state['thread_id']);
|
||||
cache()->put('support_threads_index', array_values($index), now()->addDay());
|
||||
}
|
||||
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return response()->json(['thread_id' => null, 'status' => 'closed', 'topic' => null, 'messages' => []]);
|
||||
}
|
||||
|
||||
public function message(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
$data = $request->validate(['text' => 'required|string|min:1|max:1000']);
|
||||
$text = trim($data['text']);
|
||||
$state = $this->sessionState($request);
|
||||
$enabled = $this->isChatEnabled();
|
||||
|
||||
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
||||
return response()->json(['error' => 'No active chat thread.'], 422);
|
||||
}
|
||||
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => $text, 'at' => now()->toIso8601String()];
|
||||
|
||||
if (!$enabled) {
|
||||
$lastMsg = collect($state['messages'])->where('sender', 'system')->last();
|
||||
if (!$lastMsg || !Str::contains($lastMsg['body'], 'Mitarbeiter')) {
|
||||
$state['status'] = 'handoff';
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Deine Nachricht wurde empfangen. Bitte warte auf einen Mitarbeiter.', 'at' => now()->toIso8601String()];
|
||||
}
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
if (Str::of(Str::lower($text))->contains(['ja', 'yes', 'erlaubt', 'okay', 'ok', 'zugriff'])) {
|
||||
$lastSystemMsg = collect($state['messages'])->where('sender', 'system')->last();
|
||||
if ($lastSystemMsg && Str::contains($lastSystemMsg['body'], 'zugreifen')) {
|
||||
$state['data_access_granted'] = true;
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Zugriff erlaubt. Ich sehe mir deine Daten an...', 'at' => now()->toIso8601String()];
|
||||
$aiReply = $this->askOllama($state);
|
||||
if ($aiReply) {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||
}
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
}
|
||||
|
||||
if (Str::contains($text, ['🛑', ':stop:', 'STOP'])) {
|
||||
$state['status'] = 'stopped';
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Verstanden. Soll ein Mitarbeiter übernehmen?', 'at' => now()->toIso8601String()];
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
if ($state['status'] === 'stopped' && Str::of(Str::lower($text))->contains(['ja', 'yes', 'y'])) {
|
||||
return $this->handoff($request);
|
||||
}
|
||||
|
||||
if (in_array($state['status'], ['handoff', 'agent'])) {
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
$aiReply = $this->askOllama($state);
|
||||
|
||||
if ($aiReply && (str_contains($aiReply, '[HANDOFF]') || trim($aiReply) === '[HANDOFF]')) {
|
||||
return $this->handoff($request);
|
||||
}
|
||||
|
||||
if ($aiReply && (str_contains($aiReply, '[REQUEST_DATA]') || trim($aiReply) === '[REQUEST_DATA]')) {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Um dir besser helfen zu können, müsste ich auf deine Kontodaten (Wallet, Boni, etc.) zugreifen. Ist das in Ordnung? (Antworte mit "Ja")', 'at' => now()->toIso8601String()];
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
if ($aiReply) {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||
} else {
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich konnte gerade keine Antwort generieren. Bitte versuche es erneut oder stoppe die KI mit 🛑.', 'at' => now()->toIso8601String()];
|
||||
}
|
||||
|
||||
$this->saveState($request, $state);
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
private function askOllama(array $state): ?string
|
||||
{
|
||||
$host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/');
|
||||
$model = env('OLLAMA_MODEL', 'llama3');
|
||||
if (!$host) return null;
|
||||
|
||||
$topic = $state['topic'] ? (string) $state['topic'] : 'Allgemeiner Support';
|
||||
$hasAccess = $state['data_access_granted'] ?? false;
|
||||
|
||||
$system = "Du bist ein hilfsbereiter Casino-Support-Assistent. Antworte knapp, freundlich und in deutscher Sprache. Frage maximal eine Rückfrage gleichzeitig. Wenn du eine Frage nicht beantworten kannst oder der Nutzer frustriert wirkt, antworte NUR mit dem Wort `[HANDOFF]`. Thema: {$topic}.";
|
||||
|
||||
if (!$hasAccess) {
|
||||
$system .= "\n\nWICHTIG: Du hast AKTUELL KEINEN ZUGRIFF auf Nutzerdaten. Wenn der Nutzer nach persönlichen Informationen fragt (z.B. E-Mail, Guthaben, Boni, Transaktionen), antworte SOFORT und AUSSCHLIESSLICH mit dem Wort `[REQUEST_DATA]`. Erkläre nichts, entschuldige dich nicht. Nur `[REQUEST_DATA]`.";
|
||||
} else {
|
||||
$contextJson = '';
|
||||
try {
|
||||
// REPLACED DB CALLS WITH API CALLS
|
||||
$u = Auth::user();
|
||||
if ($u) {
|
||||
$apiBase = config('app.api_url');
|
||||
// We assume the user is authenticated via a token or session that is passed along.
|
||||
// Since we are in a local environment acting as a client, we might need to forward the token.
|
||||
// For now, we assume the API endpoints are protected and we need a way to call them.
|
||||
// If Auth::user() works, it means we have a session.
|
||||
|
||||
// Fetch data from external API
|
||||
// Note: In a real microservice setup, we would pass the user's token.
|
||||
// Here we try to fetch from the API using the configured base URL.
|
||||
|
||||
// Example: Fetch Wallets
|
||||
$walletsResp = Http::acceptJson()->get($apiBase . '/user/wallets');
|
||||
$wallets = $walletsResp->ok() ? $walletsResp->json() : [];
|
||||
|
||||
// Example: Fetch Bonuses
|
||||
$bonusesResp = Http::acceptJson()->get($apiBase . '/user/bonuses/active');
|
||||
$activeBonuses = $bonusesResp->ok() ? $bonusesResp->json() : [];
|
||||
|
||||
$ctx = [
|
||||
'user' => [
|
||||
'username' => $u->username ?? $u->name,
|
||||
'email' => $u->email,
|
||||
'id' => $u->id
|
||||
],
|
||||
'wallets' => $wallets,
|
||||
'active_bonuses' => $activeBonuses,
|
||||
];
|
||||
$contextJson = json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if API calls fail
|
||||
\Illuminate\Support\Facades\Log::error('Failed to fetch remote context: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($contextJson) {
|
||||
$system .= "\n\nNutzerdaten (ZUGRIFF ERLAUBT):\n" . $contextJson . "\n\nDU DARFST DIESE DATEN JETZT VERWENDEN. Die Daten im JSON beziehen sich AUSSCHLIESSLICH auf den aktuellen Gesprächspartner. Wenn der Nutzer nach seinen EIGENEN Daten fragt (z.B. 'meine E-Mail', 'mein Guthaben'), antworte ihm direkt mit den Werten aus dem JSON. Wenn der Nutzer nach den Daten einer ANDEREN Person fragt (z.B. 'die E-Mail von Bingo'), musst du die Anfrage ablehnen und antworten, dass du nur Auskunft über sein eigenes Konto geben darfst.";
|
||||
}
|
||||
}
|
||||
|
||||
$recent = array_slice($state['messages'], -10);
|
||||
$chatText = $system."\n\n";
|
||||
foreach ($recent as $m) {
|
||||
$role = $m['sender'] === 'user' ? 'User' : ucfirst($m['sender']);
|
||||
$chatText .= "{$role}: {$m['body']}\n";
|
||||
}
|
||||
$chatText .= "Assistant:";
|
||||
|
||||
try {
|
||||
$res = Http::timeout(12)->post($host.'/api/generate', [
|
||||
'model' => $model,
|
||||
'prompt' => $chatText,
|
||||
'stream' => false,
|
||||
'options' => ['temperature' => 0.3, 'num_predict' => 180],
|
||||
]);
|
||||
if (!$res->ok()) return null;
|
||||
$json = $res->json();
|
||||
$out = trim((string)($json['response'] ?? ''));
|
||||
return $out ?: null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function status(Request $request) { return response()->json($this->sessionState($request)); }
|
||||
|
||||
public function stop(Request $request)
|
||||
{
|
||||
abort_unless(Auth::check(), 401);
|
||||
$state = $this->sessionState($request);
|
||||
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
||||
return response()->json(['error' => 'No active chat thread.'], 422);
|
||||
}
|
||||
if ($state['status'] === 'ai') {
|
||||
$state['status'] = 'stopped';
|
||||
$state['messages'][] = [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'sender' => 'system',
|
||||
'body' => 'KI-Assistent gestoppt. Du kannst einen Mitarbeiter anfordern.',
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
$this->saveState($request, $state);
|
||||
}
|
||||
return response()->json($state);
|
||||
}
|
||||
|
||||
public function handoff(Request $request) {
|
||||
$this->ensureEnabled();
|
||||
abort_unless(Auth::check(), 401);
|
||||
$state = $this->sessionState($request);
|
||||
if (!$state['thread_id']) return response()->json(['message' => 'No active support thread'], 422);
|
||||
$state['status'] = 'handoff';
|
||||
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich leite dich an einen Mitarbeiter weiter. Bitte habe einen Moment Geduld.', 'at' => now()->toIso8601String()];
|
||||
$this->saveState($request, $state);
|
||||
$webhook = env('SUPPORT_DASHBOARD_WEBHOOK_URL');
|
||||
if ($webhook) { try { Http::timeout(5)->post($webhook, ['event' => 'support.handoff', 'thread_id' => $state['thread_id']]); } catch (\Throwable $e) {} }
|
||||
return response()->json(['message' => 'Hand-off requested', 'state' => $state]);
|
||||
}
|
||||
public function stream(Request $request) {
|
||||
$state = $this->sessionState($request);
|
||||
if (empty($state['thread_id'])) return response('', 204);
|
||||
$threadId = $state['thread_id'];
|
||||
return new StreamedResponse(function () use ($request, $threadId) {
|
||||
$send = fn($data) => print('data: ' . json_encode($data) . "\n\n");
|
||||
$start = time();
|
||||
$lastUpdated = null;
|
||||
$lastCount = -1;
|
||||
while (time() - $start < 60) {
|
||||
usleep(500000);
|
||||
$cached = cache()->get('support_threads:'.$threadId);
|
||||
$nowState = is_array($cached) ? $cached : $this->sessionState($request);
|
||||
$count = count($nowState['messages'] ?? []);
|
||||
$updated = $nowState['updated_at'] ?? null;
|
||||
if ($count !== $lastCount || $updated !== $lastUpdated) {
|
||||
$send($nowState);
|
||||
$lastCount = $count;
|
||||
$lastUpdated = $updated;
|
||||
}
|
||||
if ((time() - $start) % 15 === 0) { print(": ping\n\n"); }
|
||||
@ob_flush(); @flush();
|
||||
}
|
||||
}, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', 'X-Accel-Buffering' => 'no']);
|
||||
}
|
||||
}
|
||||
146
app/Http/Controllers/TrophyController.php
Normal file
146
app/Http/Controllers/TrophyController.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GameBet;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TrophyController extends Controller
|
||||
{
|
||||
/**
|
||||
* All available achievements with their unlock conditions (for display).
|
||||
*/
|
||||
public static array $definitions = [
|
||||
'first_bet' => ['title' => 'First Bet', 'desc' => 'Place your very first bet.', 'icon' => '🎲'],
|
||||
'first_win' => ['title' => 'First Win', 'desc' => 'Win your first game.', 'icon' => '🏆'],
|
||||
'big_winner' => ['title' => 'Big Winner', 'desc' => 'Land a 10× or higher multiplier.', 'icon' => '💥'],
|
||||
'high_roller' => ['title' => 'High Roller', 'desc' => 'Wager 100+ BTX in total.', 'icon' => '💎'],
|
||||
'frequent_player' => ['title' => 'Frequent Player', 'desc' => 'Place 50+ bets.', 'icon' => '🔥'],
|
||||
'hundred_bets' => ['title' => 'Centurion', 'desc' => 'Place 100+ bets.', 'icon' => '⚡'],
|
||||
'vault_user' => ['title' => 'Vault Guardian', 'desc' => 'Make your first vault deposit.', 'icon' => '🔒'],
|
||||
'vip_level2' => ['title' => 'Rising Star', 'desc' => 'Reach VIP Level 2.', 'icon' => '⭐'],
|
||||
'vip_level5' => ['title' => 'Elite', 'desc' => 'Reach VIP Level 5.', 'icon' => '👑'],
|
||||
'guild_member' => ['title' => 'Team Player', 'desc' => 'Join a guild.', 'icon' => '🛡️'],
|
||||
'promo_user' => ['title' => 'Promo Hunter', 'desc' => 'Redeem your first promo code.', 'icon' => '🎁'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Trophy room page — show user's achievements + locked ones.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
// Sync achievements before showing
|
||||
$this->syncAchievements($user);
|
||||
|
||||
$unlocked = UserAchievement::where('user_id', $user->id)
|
||||
->pluck('unlocked_at', 'achievement_key');
|
||||
|
||||
$achievements = [];
|
||||
foreach (self::$definitions as $key => $def) {
|
||||
$achievements[] = [
|
||||
'key' => $key,
|
||||
'title' => $def['title'],
|
||||
'description' => $def['desc'],
|
||||
'icon' => $def['icon'],
|
||||
'unlocked' => isset($unlocked[$key]),
|
||||
'unlocked_at' => isset($unlocked[$key]) ? $unlocked[$key]->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort: unlocked first, then locked
|
||||
usort($achievements, fn($a, $b) => ($b['unlocked'] <=> $a['unlocked']));
|
||||
|
||||
return Inertia::render('Trophy', [
|
||||
'achievements' => $achievements,
|
||||
'total' => count(self::$definitions),
|
||||
'unlocked' => $unlocked->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trophy room for a specific user (public profiles only).
|
||||
*/
|
||||
public function show(Request $request, string $username)
|
||||
{
|
||||
$user = User::where('username', $username)->firstOrFail();
|
||||
|
||||
// Only show trophies for public profiles (or own profile)
|
||||
if (!$user->is_public && Auth::id() !== $user->id) {
|
||||
abort(403, 'This profile is private.');
|
||||
}
|
||||
|
||||
$this->syncAchievements($user);
|
||||
|
||||
$unlocked = UserAchievement::where('user_id', $user->id)
|
||||
->pluck('unlocked_at', 'achievement_key');
|
||||
|
||||
$achievements = [];
|
||||
foreach (self::$definitions as $key => $def) {
|
||||
$achievements[] = [
|
||||
'key' => $key,
|
||||
'title' => $def['title'],
|
||||
'description' => $def['desc'],
|
||||
'icon' => $def['icon'],
|
||||
'unlocked' => isset($unlocked[$key]),
|
||||
'unlocked_at' => isset($unlocked[$key]) ? $unlocked[$key]->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
|
||||
usort($achievements, fn($a, $b) => ($b['unlocked'] <=> $a['unlocked']));
|
||||
|
||||
return Inertia::render('Trophy', [
|
||||
'achievements' => $achievements,
|
||||
'total' => count(self::$definitions),
|
||||
'unlocked' => $unlocked->count(),
|
||||
'profileUser' => ['username' => $user->username, 'avatar' => $user->avatar],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and unlock achievements for the given user.
|
||||
*/
|
||||
public function syncAchievements(\App\Models\User $user): void
|
||||
{
|
||||
$bets = GameBet::where('user_id', $user->id);
|
||||
$betCount = $bets->count();
|
||||
$totalWager = $bets->sum('wager_amount');
|
||||
$maxMulti = $bets->max('payout_multiplier') ?? 0;
|
||||
$hasWin = $bets->where('payout_amount', '>', 0)->exists();
|
||||
|
||||
$toUnlock = [];
|
||||
|
||||
if ($betCount >= 1) $toUnlock[] = 'first_bet';
|
||||
if ($hasWin) $toUnlock[] = 'first_win';
|
||||
if ($maxMulti >= 10) $toUnlock[] = 'big_winner';
|
||||
if ($totalWager >= 100) $toUnlock[] = 'high_roller';
|
||||
if ($betCount >= 50) $toUnlock[] = 'frequent_player';
|
||||
if ($betCount >= 100) $toUnlock[] = 'hundred_bets';
|
||||
if ($user->vip_level >= 2) $toUnlock[] = 'vip_level2';
|
||||
if ($user->vip_level >= 5) $toUnlock[] = 'vip_level5';
|
||||
if ($user->guildMember()->exists()) $toUnlock[] = 'guild_member';
|
||||
|
||||
// Vault usage
|
||||
if (\App\Models\WalletTransfer::where('user_id', $user->id)->where('type', 'deposit')->exists()) {
|
||||
$toUnlock[] = 'vault_user';
|
||||
}
|
||||
// Promo usage
|
||||
if (\App\Models\PromoUsage::where('user_id', $user->id)->exists()) {
|
||||
$toUnlock[] = 'promo_user';
|
||||
}
|
||||
|
||||
foreach ($toUnlock as $key) {
|
||||
UserAchievement::firstOrCreate(
|
||||
['user_id' => $user->id, 'achievement_key' => $key],
|
||||
['unlocked_at' => now()]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/UserBonusController.php
Normal file
54
app/Http/Controllers/UserBonusController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserBonusController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Return authenticated user's bonuses (active first), lightweight JSON.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->get($request, '/users/me/bonuses', [], retry: true);
|
||||
if ($res->successful()) {
|
||||
$body = $res->json() ?: [];
|
||||
$items = $body['data'] ?? $body['bonuses'] ?? $body;
|
||||
$out = [];
|
||||
foreach ((array) $items as $b) {
|
||||
if (!is_array($b)) continue;
|
||||
$out[] = [
|
||||
'id' => $b['id'] ?? null,
|
||||
'amount' => isset($b['amount']) ? (float) $b['amount'] : 0.0,
|
||||
'wager_required' => isset($b['wager_required']) ? (float) $b['wager_required'] : 0.0,
|
||||
'wager_progress' => isset($b['wager_progress']) ? (float) $b['wager_progress'] : 0.0,
|
||||
'expires_at' => $b['expires_at'] ?? null,
|
||||
'is_active' => (bool) ($b['is_active'] ?? false),
|
||||
'completed_at' => $b['completed_at'] ?? null,
|
||||
'promo' => isset($b['promo']) && is_array($b['promo']) ? [
|
||||
'code' => $b['promo']['code'] ?? null,
|
||||
'wager_multiplier' => isset($b['promo']['wager_multiplier']) ? (int) $b['promo']['wager_multiplier'] : null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
return response()->json(['data' => $out], 200);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/UserRestrictionApiController.php
Normal file
171
app/Http/Controllers/UserRestrictionApiController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserRestrictionApiController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
private array $allowedTypes = [
|
||||
'account_ban',
|
||||
'chat_ban',
|
||||
'deposit_block',
|
||||
'withdrawal_block',
|
||||
'support_block',
|
||||
];
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
// Inline Bearer token middleware like BonusApiController
|
||||
$this->middleware(function ($request, $next) {
|
||||
$provided = $this->extractToken($request);
|
||||
$expected = config('services.moderation_api.token');
|
||||
|
||||
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): ?string
|
||||
{
|
||||
$auth = $request->header('Authorization');
|
||||
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||
return substr($auth, 7);
|
||||
}
|
||||
return $request->query('api_token');
|
||||
}
|
||||
|
||||
// GET /api/users/{id}/restrictions?active_only=1
|
||||
public function listForUser(Request $request, int $userId)
|
||||
{
|
||||
try {
|
||||
$query = [];
|
||||
if ($request->boolean('active_only')) {
|
||||
$query['active_only'] = 1;
|
||||
}
|
||||
$res = $this->client->get($request, "/users/{$userId}/restrictions", $query, retry: true);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/users/{id}/restrictions/check
|
||||
public function checkForUser(Request $request, int $userId)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->get($request, "/users/{$userId}/restrictions/check", [], retry: true);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users/{id}/restrictions
|
||||
public function createForUser(Request $request, int $userId)
|
||||
{
|
||||
$data = $this->validatePayload($request, partial: false);
|
||||
try {
|
||||
$res = $this->client->post($request, "/users/{$userId}/restrictions", $data);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: [], 201);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users/{id}/restrictions/upsert
|
||||
public function upsertForUser(Request $request, int $userId)
|
||||
{
|
||||
$data = $this->validatePayload($request, partial: true);
|
||||
if (empty($data['type'])) {
|
||||
return response()->json(['message' => 'type is required for upsert'], 422);
|
||||
}
|
||||
try {
|
||||
$res = $this->client->post($request, "/users/{$userId}/restrictions/upsert", $data);
|
||||
if ($res->successful()) {
|
||||
// 200 or 201 upstream; forward as given
|
||||
$status = $res->status() === 201 ? 201 : 200;
|
||||
return response()->json($res->json() ?: [], $status);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/restrictions/{id}
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$data = $this->validatePayload($request, partial: true);
|
||||
try {
|
||||
$res = $this->client->patch($request, "/restrictions/{$id}", $data);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: []);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/restrictions/{id}
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
try {
|
||||
$res = $this->client->delete($request, "/restrictions/{$id}");
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: ['message' => 'Deactivated']);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
$rules = [
|
||||
'type' => [$required, Rule::in($this->allowedTypes)],
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'imposed_by' => ['sometimes', 'nullable', 'integer'],
|
||||
'starts_at' => ['sometimes', 'nullable', 'date'],
|
||||
'ends_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
'source' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'metadata' => ['sometimes', 'nullable', 'array'],
|
||||
];
|
||||
$validated = $request->validate($rules);
|
||||
return $validated;
|
||||
}
|
||||
}
|
||||
124
app/Http/Controllers/VaultApiController.php
Normal file
124
app/Http/Controllers/VaultApiController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VaultApiController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
// Inline token check middleware (no alias registration needed)
|
||||
$this->middleware(function ($request, $next) {
|
||||
$provided = $this->extractToken($request);
|
||||
$expected = config('services.vault_api.token');
|
||||
|
||||
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): ?string
|
||||
{
|
||||
$auth = $request->header('Authorization');
|
||||
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||
return substr($auth, 7);
|
||||
}
|
||||
return $request->query('api_token'); // fallback: allow ?api_token=
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/users/{id}/vault
|
||||
*/
|
||||
public function showForUser(Request $request, int $id)
|
||||
{
|
||||
$perPage = min(200, max(1, (int) $request->query('per_page', 50)));
|
||||
try {
|
||||
$res = $this->client->get($request, "/users/{$id}/vault", [ 'per_page' => $perPage ], retry: true);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: [], 200);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users/{id}/vault/deposit
|
||||
*/
|
||||
public function depositForUser(Request $request, int $id)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||
'reason' => ['sometimes','nullable','string','max:255'],
|
||||
'created_by' => ['sometimes','nullable','integer'],
|
||||
]);
|
||||
|
||||
// Pass through metadata we can gather; upstream decides usage
|
||||
$payload = [
|
||||
'amount' => $data['amount'],
|
||||
'idempotency_key' => $data['idempotency_key'] ?? null,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
];
|
||||
|
||||
try {
|
||||
$res = $this->client->post($request, "/users/{$id}/vault/deposit", $payload);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: [], 201);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users/{id}/vault/withdraw
|
||||
*/
|
||||
public function withdrawForUser(Request $request, int $id)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||
'reason' => ['sometimes','nullable','string','max:255'],
|
||||
'created_by' => ['sometimes','nullable','integer'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'amount' => $data['amount'],
|
||||
'idempotency_key' => $data['idempotency_key'] ?? null,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
];
|
||||
|
||||
try {
|
||||
$res = $this->client->post($request, "/users/{$id}/vault/withdraw", $payload);
|
||||
if ($res->successful()) {
|
||||
return response()->json($res->json() ?: [], 201);
|
||||
}
|
||||
if ($res->clientError()) return $this->mapClientError($res);
|
||||
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||
return $this->mapBadGateway();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->mapBadGateway('API server not reachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/VaultController.php
Normal file
121
app/Http/Controllers/VaultController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WalletTransfer;
|
||||
use App\Services\WalletService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class VaultController extends Controller
|
||||
{
|
||||
public function __construct(private readonly WalletService $wallet)
|
||||
{
|
||||
}
|
||||
|
||||
private const SUPPORTED_CURRENCIES = ['BTX', 'BTC', 'ETH', 'SOL'];
|
||||
|
||||
/**
|
||||
* GET /api/wallet/vault — returns balances for all currencies
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$perPage = min(100, max(1, (int) $request->query('per_page', 20)));
|
||||
$items = WalletTransfer::where('user_id', $user->id)
|
||||
->orderByDesc('id')
|
||||
->limit($perPage)
|
||||
->get(['id','type','amount','currency','created_at']);
|
||||
|
||||
$transfers = $items->map(fn($t) => [
|
||||
'id' => $t->id,
|
||||
'type' => $t->type,
|
||||
'amount' => (string) $t->amount,
|
||||
'currency' => $t->currency,
|
||||
'created_at' => $t->created_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
$map = $user->vault_balances ?? [];
|
||||
|
||||
return response()->json([
|
||||
'balance' => (string) ($user->balance ?? '0.0000'),
|
||||
'vault_balance' => (string) ($user->vault_balance ?? '0.0000'),
|
||||
'vault_balances' => array_merge(
|
||||
['BTX' => (string) ($user->vault_balance ?? '0.0000')],
|
||||
$map
|
||||
),
|
||||
'currency' => 'BTX',
|
||||
'transfers' => $transfers,
|
||||
'now' => now()->toIso8601String(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/wallet/vault/deposit
|
||||
*/
|
||||
public function deposit(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||
'currency' => ['sometimes','string','in:' . implode(',', self::SUPPORTED_CURRENCIES)],
|
||||
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||
]);
|
||||
|
||||
$currency = strtoupper($data['currency'] ?? 'BTX');
|
||||
|
||||
if ($resp = $this->wallet->verifyVaultPin($user, (string) $data['pin'])) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$out = $this->wallet->depositToVault($user, $data['amount'], $data['idempotency_key'] ?? null, $currency);
|
||||
|
||||
return response()->json([
|
||||
'data' => ['type' => 'deposit', 'amount' => $data['amount'], 'currency' => $currency],
|
||||
'balances' => [
|
||||
'balance' => $out['balance'],
|
||||
'vault_balance' => $out['vault_balance'],
|
||||
'vault_balances' => $out['vault_balances'],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/wallet/vault/withdraw
|
||||
*/
|
||||
public function withdraw(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||
'currency' => ['sometimes','string','in:' . implode(',', self::SUPPORTED_CURRENCIES)],
|
||||
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||
]);
|
||||
|
||||
$currency = strtoupper($data['currency'] ?? 'BTX');
|
||||
|
||||
if ($resp = $this->wallet->verifyVaultPin($user, (string) $data['pin'])) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$out = $this->wallet->withdrawFromVault($user, $data['amount'], $data['idempotency_key'] ?? null, $currency);
|
||||
|
||||
return response()->json([
|
||||
'data' => ['type' => 'withdraw', 'amount' => $data['amount'], 'currency' => $currency],
|
||||
'balances' => [
|
||||
'balance' => $out['balance'],
|
||||
'vault_balance' => $out['vault_balance'],
|
||||
'vault_balances' => $out['vault_balances'],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/VaultPinController.php
Normal file
86
app/Http/Controllers/VaultPinController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class VaultPinController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/wallet/vault/pin/set — local implementation
|
||||
*/
|
||||
public function set(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||
'current_pin' => ['sometimes','nullable','string','regex:/^\d{4,8}$/'],
|
||||
]);
|
||||
|
||||
// If PIN already set, require current_pin and verify
|
||||
if (!empty($user->vault_pin_hash)) {
|
||||
if (empty($data['current_pin']) || !Hash::check((string) $data['current_pin'], $user->vault_pin_hash)) {
|
||||
return response()->json(['message' => 'Current PIN invalid'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$user->vault_pin_hash = Hash::make((string) $data['pin']);
|
||||
$user->vault_pin_set_at = now();
|
||||
$user->vault_pin_attempts = 0;
|
||||
$user->vault_pin_locked_until = null;
|
||||
$user->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Vault PIN saved.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/wallet/vault/pin/verify — local implementation
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||
]);
|
||||
|
||||
// Locked?
|
||||
if (!empty($user->vault_pin_locked_until) && now()->lessThan($user->vault_pin_locked_until)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Vault PIN locked. Try again later.',
|
||||
'locked_until' => optional($user->vault_pin_locked_until)->toIso8601String(),
|
||||
], 423);
|
||||
}
|
||||
if (empty($user->vault_pin_hash)) {
|
||||
return response()->json(['success' => false, 'message' => 'Vault PIN not set'], 400);
|
||||
}
|
||||
if (!Hash::check((string) $data['pin'], $user->vault_pin_hash)) {
|
||||
$attempts = (int) ($user->vault_pin_attempts ?? 0) + 1;
|
||||
$user->vault_pin_attempts = $attempts;
|
||||
if ($attempts >= 5) {
|
||||
$user->vault_pin_locked_until = now()->addMinutes(15);
|
||||
$user->vault_pin_attempts = 0;
|
||||
}
|
||||
$user->save();
|
||||
return response()->json(['success' => false, 'message' => 'Invalid PIN'], 423);
|
||||
}
|
||||
// OK
|
||||
if (!empty($user->vault_pin_attempts)) {
|
||||
$user->vault_pin_attempts = 0;
|
||||
$user->save();
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Verified',
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/VipController.php
Normal file
85
app/Http/Controllers/VipController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||
use App\Services\BackendHttpClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class VipController extends Controller
|
||||
{
|
||||
use ProxiesBackend;
|
||||
|
||||
public function __construct(private readonly BackendHttpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* VIP Levels page → proxy to external API.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$defaultProps = [
|
||||
'claimedLevels' => [],
|
||||
'cashRewards' => [],
|
||||
'userStats' => $user ? [
|
||||
'vip_level' => $user->vip_level ?? 0,
|
||||
'vip_points' => $user->stats?->vip_points ?? 0,
|
||||
] : null,
|
||||
'userVipLevel' => $user?->vip_level ?? 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$res = $this->client->get($request, '/vip-levels', [], retry: true);
|
||||
if ($res->successful()) {
|
||||
$j = $res->json() ?: [];
|
||||
return Inertia::render('VipLevels', [
|
||||
'claimedLevels' => $j['claimedLevels'] ?? $j['claimed_levels'] ?? [],
|
||||
'cashRewards' => $j['cashRewards'] ?? $j['rewards'] ?? [],
|
||||
'userStats' => $j['userStats'] ?? $j['stats'] ?? $defaultProps['userStats'],
|
||||
'userVipLevel' => $j['userVipLevel'] ?? $j['vip_level'] ?? $defaultProps['userVipLevel'],
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to local fallback
|
||||
}
|
||||
|
||||
// Render page with local data when external API is unavailable
|
||||
return Inertia::render('VipLevels', $defaultProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim VIP reward → proxy to external API.
|
||||
*/
|
||||
public function claim(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'level' => 'required|integer|min:1|max:100',
|
||||
]);
|
||||
try {
|
||||
$res = $this->client->post($request, '/vip-levels/claim', [
|
||||
'level' => (int) $data['level'],
|
||||
]);
|
||||
if ($res->successful()) {
|
||||
$body = $res->json() ?: [];
|
||||
// Backward compat: ensure a success message
|
||||
if (!isset($body['message'])) {
|
||||
$body['message'] = 'Reward claimed successfully!';
|
||||
}
|
||||
return back()->with('success', $body['message']);
|
||||
}
|
||||
if ($res->clientError()) {
|
||||
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||
return back()->withErrors(['message' => $msg]);
|
||||
}
|
||||
if ($res->serverError()) {
|
||||
return back()->withErrors(['message' => 'Service temporarily unavailable']);
|
||||
}
|
||||
return back()->withErrors(['message' => 'API server not reachable']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['message' => 'API server not reachable']);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/WalletController.php
Normal file
89
app/Http/Controllers/WalletController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use App\Models\GameBet;
|
||||
|
||||
class WalletController extends Controller
|
||||
{
|
||||
/**
|
||||
* Wallet overview page — now fully local (no external API).
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
// Synchronize balance if possible from external API during page load for better initial state
|
||||
// (Optional, maybe already done in Proxy if user just came from a game)
|
||||
|
||||
// Frontend expects:
|
||||
// - wallets: array of currency accounts (we return an empty list for now; extend later if needed)
|
||||
// - btxBalance: main BTX balance as number/string
|
||||
$wallets = [];
|
||||
$btxBalance = (string) ($user->balance ?? '0');
|
||||
|
||||
return Inertia::render('Wallet', [
|
||||
'wallets' => $wallets,
|
||||
'btxBalance' => $btxBalance,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/wallet/balance — return current authenticated user balance
|
||||
*/
|
||||
public function balance(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return response()->json(['error' => 'unauthorized'], 401);
|
||||
|
||||
return response()->json([
|
||||
'balance' => (string) $user->balance,
|
||||
'btx_balance' => (string) $user->balance,
|
||||
// Add other currencies if user has multiple wallets
|
||||
'wallets' => $user->wallets()->get()->map(fn($w) => [
|
||||
'currency' => $w->currency,
|
||||
'balance' => (string) $w->balance,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wallet/bets — fetch user's game bets
|
||||
*/
|
||||
public function bets(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$query = GameBet::where('user_id', $user->id);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('game_name', 'like', '%' . $request->input('search') . '%');
|
||||
}
|
||||
|
||||
$sort = $request->input('sort', 'created_at');
|
||||
$order = $request->input('order', 'desc');
|
||||
|
||||
if (in_array($sort, ['created_at', 'wager_amount', 'payout_amount', 'payout_multiplier', 'game_name'])) {
|
||||
$query->orderBy($sort, $order === 'asc' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
$bets = $query->paginate(20)->through(function ($bet) {
|
||||
return [
|
||||
'id' => $bet->id,
|
||||
'game_name' => $bet->game_name,
|
||||
'wager_amount' => (string) $bet->wager_amount,
|
||||
'payout_multiplier' => (string) $bet->payout_multiplier,
|
||||
'payout_amount' => (string) $bet->payout_amount,
|
||||
'currency' => $bet->currency,
|
||||
'created_at' => $bet->created_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($bets);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user